[Swift] Actor Reentrancy

reentrancy가 무엇인지에 대해 알아볼 필요가 있다. reentrancy는 다중 호출이 다중 프로세서에서 동시에 안전하게 실행될 수 있는 경우, 또는 단일 프로세서 시스템에서 재진입 프로시저가 실행 중간에 중단된 다음 이전 호출이 실행을 완료하기 전에 안전하게 다시 호출 되는 경우를 말한다.

apple proposal에서 친절하게 위키 문서를 링크를 걸어줘서 참고했다. (https://en.wikipedia.org/wiki/Reentrancy_(computing))

영어실력이 네이티브가 아니라 번역기를 대부분 사용했으니, 해석이 올바르지 않을 수 있다. 특이사항이 있다면 본문을 참고 권유한다.

Reentrancy는 두 행위자가 서로 의존하는 교착 상태의 원인을 제거하고, 행위자에 대한 작업을 불필요하게 차단하지 않음으로써 전체 성능을 향상시킬 수 있으며, 우선순위가 더 높은 작업(예: 더 나은 스케줄링)을 위한 기회를 제공한다. 그러나 이것은 interleaved task가 해당 state를 변경할 때 actor-isolated state가 await에서 변경될 수 있음을 의미한다. 즉 개발자는 await에서 불변성을 깨뜨리지 않도록 해야한다.

일반적으로 이것이 비동기 호출에 await를 요구하는 이유다. 호출이 suspend되면 다양한 state가 변경될 수 있기 때문이다. (정확히 suspend가 뭘 뜻하는지 모르겠음 - suspend 된다는 것은 await 하는 동안에 다른 작업이 할당되어있는 시점을 말하는 듯 하다.)

**“Interleaving” execution with reentrant actors**

여기서의 “Interleaving”이라는 뜻은 아직까지도 정확히 이해하고 있지 않지만, suspend 지점에서 다른 작업이 실행됨에 따라 mutable state가 thread safe하지만 race condition이 발생하여 의도하지 않은 결과값을 야기할 수 있다는 의미인 것 같다.

메모리 인터리빙 (memory interleaving)은 주기억장치 를 접근하는 속도를 빠르게 하는데 사용된다. 메모리 인터리빙 기법은 인접한 메모리 위치를 서로 다른 메모리 뱅크 (bank)에 둠으로써 동시에 여러 곳을 접근할 수 있게 하는 것이다. 메모리 인터리빙은 블록 단위 전송이 가능하게 하므로 캐시나 기억장치와 주변장치 사이의 빠른 데이터 전송을 위한 DMA (Direct Memory Access)에서 많이 사용한다.

actor Person {
  let friend: Friend
  
  // actor-isolated opinion
  var opinion: Judgment = .noIdea

  func thinkOfGoodIdea() async -> Decision {
    opinion = .goodIdea                       // <1>
    await friend.tell(opinion, heldBy: self)  // <2>
    return opinion // 🤨                      // <3>
  }

  func thinkOfBadIdea() async -> Decision {
    opinion = .badIdea                       // <4>
    await friend.tell(opinion, heldBy: self) // <5>
    return opinion // 🤨                     // <6>
  }
}

let goodThink = detach { await person.thinkOfGoodIdea() }  // runs async
let badThink = detach { await person.thinkOfBadIdea() } // runs async

let shouldBeGood = await goodThink.get()
let shouldBeBad = await badThink.get()

await shouldBeGood // could be .goodIdea or .badIdea ☠️
await shouldBeBad

위 코드를 실행할 경우 아래와 같은 실행 순서로 실행된다.

opinion = .goodIdea                // <1>
// suspend: await friend.tell(...) // <2>
opinion = .badIdea                 // | <4> (!)
// suspend: await friend.tell(...) // | <5>
// resume: await friend.tell(...)  // <2>
return opinion                     // <3>
// resume: await friend.tell(...)  // <5>
return opinion                     // <6>

suspend 지점에서 interleave 실행의 가능성은 모든 suspend 지점을 await로 표시해야하는 이유이다. await는 전제에서 어떠한 shared state가 변경될 수 있음을 나타내는 지표다.

따라서 await에서 불변성을 깨뜨리거나, await의 이전 state를 이후 state와 동일하게 판단하는 것은 피해야한다.

일반적으로 말해서, 이러한 현상을 피하는 가장 쉬운 방법은 synchronous actor function에서 state의 update를 캡슐화하는 방법이다. 효과적으로, actor의 synchronous code는 critical section을 제공하는 반면에 await는 critical section을 중단한다.