Swift exclusive memory access

Hello, I have a question about exclusive memory access:

var stepSize = 1

func increment(_ number: inout Int) {
    number += stepSize
}

increment(&stepSize)

Is this illegal because the machine code generated by Swift might not actually store a + b in a separate memory location from where a is stored, and thus there would be an assembly level conflict when number and stepSize refer to the same memory location?
Or is this illegal for a completely different reason?

Thank you

2 Likes

Not at all.

When you reference stepSize in increment(_:), you are using the "old" version of it. That is because you can mutate something to a value which you will mutate with.

Also, I ran it in a playground without and BAD_ACCESS errors.

Another way to represent the question is by this:

stepSize += 1

Here, what happens is:

stepSize = stepSize + 1

This is not illegal code. So you can see that your code is also perfectly legal.

That would be a bug? It shouldn’t do that.

It’s illegal for completely different reason. Even if they both refer to the same memory, cpu still need to load them into register first, and may choose different register if needed (which I don’t think is needed in this case), so there is never a conflict there.

This is because Swift enforces exclusive write access

Yes, as Lantua notes that's really surprising to see it working correctly from playgrounds. Running in from Xcode command line template or from a SPM executable does cause the trap.

EDIT : I don't know if they work the same way internally, but the REPL doesn't notice it either.

Should I feel a bug? Where?

You can do it from bugs.swift.org
By the way, the static checks are done as something like foo(&a, &a) fails, but the dynamic checks don't, that's surprising!

The exclusivity checking bug for the REPL is addressed by this PR: [SwiftREPL] Runtime checks for exclusive access should trap. by dcci · Pull Request #1183 · apple/swift-lldb · GitHub, and the playground bug is tracked by rdar://problem/33831489.

1 Like

Bug filed here.

Nice to know this has been fixed for the next versions! Coming back to my original question, does somebody know the reason why this code is illegal? :upside_down_face:

How much do you want to dig into the rationale behind Swift's memory model?

I don't think the code in the example is problematic, except that it violates exclusivity rules. It's sort of like how if a train is on a set schedule, but the driver sees someone running to board as its just starting to accelerate, maybe it'll stop and let the person on. Except Swift does the strict thing because it's a compiler and not a person, and doesn't stop accelerating. :face_with_raised_eyebrow:


Maybe a simple explanation is that Swift needed to come up with some formal rules for how it manages storage/memory access. The potential benefit of coming up with rules is that Swift could gain performance by making fewer copies of values if it knew it could trust that the whole program was obeying the same ruleset, and the other benefit is that you avoid "spooky action at a distance" with accidentally shared mutable state.

One of the rules is that a program can't perform a read on a particular memory location while another part of that program is already performing a write operation on that memory location. And it turns out that write operations can be non-instantaneous, like for the whole duration of a function call (and that function can call other functions, and so on).

In the example you cited; stepSize is marked as being mutated for the whole duration of increment(_:). The code inside increment(_:) tries to read stepSize, and that's when the exclusivity violation occurs.

Personally, I find it easier to see the problem when looking at the generated SIL. I inlined and indented the body of increment(_:) into the main function so it's a bit easier to see the overlap:

// main
sil @main : $@convention(c) (Int32, UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>) -> Int32 {
bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
  alloc_global @$s4main8stepSizeSivp              // id: %2
  %3 = global_addr @$s4main8stepSizeSivp : $*Int  // users: %6, %7
  %4 = integer_literal $Builtin.Int64, 1          // user: %5
  %5 = struct_ $Int (%4 : $Builtin.Int64)          // user: %6
  store %5 to %3 : $*Int                          // id: %6
  
  // âś… Here's where the modify access to `stepSize` starts.
  %7 = begin_access [modify] [dynamic] %3 : $*Int // users: %10, %9
  // function_ref increment(_:)
  %8 = function_ref @$s4main9incrementyySizF : $@convention(thin) (@inout Int) -> () // user: %9
  %9 = apply %8(%7) : $@convention(thin) (@inout Int) -> ()
  
      // This part here is where increment(_:) is called.
      sil hidden @$s4main9incrementyySizF : $@convention(thin) (@inout Int) -> () {
      // %0                                             // users: %6, %2
      bb0(%0 : $*Int):
        %1 = global_addr @$s4main8stepSizeSivp : $*Int  // user: %3
        debug_value_addr %0 : $*Int, var, name "number", argno 1 // id: %2
        
        // ❌ Here's where the conflict happens, we're reading a memory location
        // that another part of the code already has exclusive access to.
        %3 = begin_access [read] [dynamic] %1 : $*Int   // users: %4, %5
        %4 = load %3 : $*Int                            // user: %9
        end_access %3 : $*Int                           // id: %5
        %6 = begin_access [modify] [static] %0 : $*Int  // users: %16, %7, %18
        %7 = struct_element_addr %6 : $*Int, #Int._value // user: %8
        %8 = load %7 : $*Builtin.Int64                  // user: %11
        %9 = struct_extract %4 : $Int, #Int._value      // user: %11
        %10 = integer_literal $Builtin.Int1, -1         // user: %11
        %11 = builtin "sadd_with_overflow_Int64"(%8 : $Builtin.Int64, %9 : $Builtin.Int64, %10 : $Builtin.Int1) : $(Builtin.Int64, Builtin.Int1) // users: %13, %12
        %12 = tuple_extract %11 : $(Builtin.Int64, Builtin.Int1), 0 // user: %15
        %13 = tuple_extract %11 : $(Builtin.Int64, Builtin.Int1), 1 // user: %14
        cond_fail %13 : $Builtin.Int1                   // id: %14
        %15 = struct_ $Int (%12 : $Builtin.Int64)        // user: %16
        store %15 to %6 : $*Int                         // id: %16
        %17 = tuple ()
        end_access %6 : $*Int                           // id: %18
        %19 = tuple ()                                  // user: %20
        return %19 : $()                                // id: %20
      } // end sil function '$s4main9incrementyySizF'

  
  // âś… Here's where the modify access ends. 
  end_access %7 : $*Int                           // id: %10
  
  // After this it would be okay to access `stepSize` in other code.
  %11 = integer_literal $Builtin.Int32, 0         // user: %12
  %12 = struct_ $Int32 (%11 : $Builtin.Int32)      // user: %13
  return %12 : $Int32                             // id: %13
}
1 Like

For the information, as I mentioned in passing, try to look into Memory Safety. It does explain your case too.

@Andrew_Trick recently showed me a variation of your example that makes the issue clearer:

var stepSize = 1

func increment(_ number: inout Int) {
    number += stepSize
    number += stepSize
}

increment(&stepSize) 
print(stepSize) // 3 or 4?

The equivalent C code is required to print 4, but it's not obvious that that's what the user wanted, and it inhibits optimizations to do it. Formally, Swift inout uses a copy-in/copy-out model, which would print 3, but it's a useful optimization for that to do in-place updates when it can. Therefore, for both semantic and performance reasons, we declare this code invalid.

2 Likes

I thought inout was defined to behave “as if” the argument was copied in, then copied out again:

var stepSize = 1

func increment(_ number: inout Int) {
    var x = number
    x += stepSize
    x += stepSize
    number = x
}

increment(&stepSize) 
print(stepSize) // definitely prints 3

That's correct. But before we had the exclusivity rules, the implementation didn't match the definition, because it's a pretty critical optimization to not actually operate on a copy all the time. (In particular, it's important for mutating methods on copy-on-write types like Array to not copy on every write if the buffer is already uniquely referenced.)

EDIT: Strictly, the "as-if" behavior looks like this:

var stepSize = 1

func increment(_ number: inout Int) {
    number += stepSize
    number += stepSize
}

var x = stepSize
increment(&x)
stepSize = x
print(stepSize) // definitely prints 3

because that's the form that makes sense for computed variables too.

EDIT 2: This is also no longer the correct "as if" form in the presence of the new _modify accessor, but we don't really have the syntax to talk about what that's doing yet.

2 Likes

That's interesting, thank you for the SIL example. I'll take a look at https://github.com/apple/swift/blob/master/docs/OwnershipManifesto.md to see how this exclusive access rule plays with the upcoming memory ownership rules.

Thank you for the example, that's indeed clearer, especially since it highlights Swift's design choice of using a clear memory model. When reading the original example, I thought this limitation was due to potentially unsafe machine code being generated.