[5] Committed CommandBuffer destruction thread decided by release ordering
Medium. 명시적인 owner와 release 시점의 drain이 사라지고, 그 자리를 내부 strong capture 두 개가 대신합니다. 그래서 둘 중 어느 쪽이 마지막 release를 수행하느냐에 따라 ~CommandBuffer()가 실행되는 thread가 결정됩니다. 가능한 ordering 가운데 일부에서만, 그 thread가 non-atomic child refcount와 device의 encoder map을 동시에 건드리는 위치에 놓이게 됩니다.
WebKit의 WebGPU 구현은 GPU process에서 동작하며, WebContent에서 오는 IPC 메시지를 받아 실행됩니다. 기록된 command buffer는 Metal로 전달되고, GPU 작업이 끝나면 Metal이 자신이 선택한 thread에서 callback을 호출합니다. 이때 commit된 GPU 작업 단위인 CommandBuffer는 thread-safe reference counting을 사용합니다. 반면 CommandBuffer가 참조를 들고 있는 CommandEncoder, 즉 그 명령들을 실제로 기록한 객체는 RefCountedAndCanMakeWeakPtr을 상속하므로 atomic이 아닌 counter를 씁니다. 이 조합이 안전하려면, command buffer의 마지막 release가 encoder의 counter와 device의 encoder map을 건드려도 되는 thread에서 일어나야 합니다.
관전 포인트: 마지막 release가 Metal의 completion thread에서 일어나면, non-atomic refcount와 device의 encoder map이 WebGPU IPC를 처리하는 thread와 동시에 변경됩니다. count가 깨지고 객체가 조기에 파괴되는 전형적인 형태입니다.
Patch Details
CommandBuffer::makeInvalidDueToCommit()는 더 이상 Ref<CommandBuffer>를 Instance::retainCommandBuffer()에 전달하지 않습니다. 그래서 Instance가 소유하던 m_retainedCommandBufferInstances 컨테이너도 Metal의 completion callback 이후까지 commit된 buffer를 붙잡아 두지 않게 되었습니다. 함께 wgpuInstanceRelease 안에 있던 waitForCommandBufferCompletions(), 즉 아직 처리 중인 작업을 모두 기다려 비우던 drain도 제거되었습니다. 그 대신 Queue::scheduleWork continuation이 strong protectedThis 사본을 다시 갖게 되었습니다. 이전에는 ThreadSafeWeakPtr만 capture하고 있었습니다.
여러 내부 reference 중 마지막에 release되는 쪽으로 ownership이 넘어가면서, cross-thread 객체의 파괴 thread가 설계가 아니라 타이밍에 의해 결정되는 구조.
Ordering A (benign) Ordering B (hazardous)
------------------- ----------------------
Metal releases the block the work item runs and is
while the work item is still destroyed before Metal
pending releases the block
| |
the work item holds the last Ref the block release performs
| the final deref()
~CommandBuffer() runs on the |
work item's thread ~CommandBuffer() runs on
Metal's completion thread
|
non-atomic CommandEncoder
deref + Device map mutation
race the IPC-servicing thread
Background
이 코드가 있는 위치. Source/WebGPU/WebGPU/는 WebGPU IPC 표면 뒤에 있는 네이티브 구현이며, WebContent에서 보내는 RemoteDevice, RemoteCommandEncoder, RemoteCommandBuffer, RemoteQueue 메시지로 구동됩니다. 그중 객체 lifetime의 핵심을 이루는 것이 CommandBuffer, CommandEncoder, Device, Instance입니다.
Metal completion handler. Metal은 addCompletedHandler:로 등록된 block을 자체 내부 thread에서 호출합니다. block이 capture한 값들은 framework가 그 block을 release할 때까지 살아 있습니다. 이 release 시점은 handler가 반환되는 시점과는 별개의 사건입니다.
두 가지 refcount 방식. CommandBuffer는 ThreadSafeRefCountedAndCanMakeThreadSafeWeakPtr을 상속합니다. 반면 CommandEncoder는 RefCountedAndCanMakeWeakPtr(CommandEncoder.h)을 상속하며, 여기에 쓰이는 counter는 atomic이 아닙니다. 이 차이가 각 객체에 대해 어떤 thread에서 ref()/deref()를 호출해도 되는지를 결정합니다.
Device의 encoder map. Device::createCommandEncoder()는 RemoteDevice IPC 메시지를 처리하는 thread에서 m_commandEncoderMap.set(commandEncoder->uniqueId(), commandEncoder.ptr())을 기록합니다. 같은 map에서 항목을 지우는 쪽은 Device::removeCommandEncoder(m_uniqueId)입니다. 이 map에는 lock이 없습니다.
Analysis
패치 이후 makeInvalidDueToCommit()는 commit된 CommandBuffer에 대해 backend 내부 strong reference를 두 개 만듭니다. 하나는 addCompletedHandler: block에 값으로 capture되는 쪽(protectedThis = protect(*this))이고, 다른 하나는 그 block이 예약된 work item에 넣는 사본입니다. 물론 다른 strong reference도 한동안 함께 존재합니다. Queue::submit이 Vector<Ref<CommandBuffer>>를 받고, WebKit 쪽 remote wrapper 역시 Destruct 메시지가 올 때까지 자체 reference를 들고 있을 가능성이 높습니다. 다만 그 wrapper는 IPC 경계의 WebKit 쪽에 있으므로 이 backend 범위 밖입니다. 이들이 모두 release되고 나면, 마지막 release는 앞서 언급한 내부 capture 두 개 중 하나에서 나옵니다. 어느 쪽이 마지막이 되느냐가 곧 파괴 thread를 결정하게 됩니다.
다시 들어온 strong capture 자체도 lifetime anchor 역할을 하며, 제거된 pin이 제공하던 보호의 일부를 대신합니다. 즉 단순한 제거가 아니라 부분적인 대체에 해당합니다. 다만 Metal이 addCompletedHandler: block을 handler 반환 시점에 대해 언제 release하는지는 framework 내부 구현 사항입니다. 그래서 다이어그램의 두 ordering이 모두 살아 있게 됩니다. ordering A에서는 work item이 마지막 reference를 들고 있으므로, ~CommandBuffer()는 그 item을 실행하거나 파괴하는 thread에서 동작하게 됩니다. 남는 위험은 ordering B입니다. work item이 Metal의 block release보다 먼저 실행되고 파괴되는 경우, 또는 schedule-work 구현이 그 item을 completion thread에서 동기적으로 dispatch하는 경우를 가정할 수 있습니다. 이때는 Metal의 release가 마지막 deref()를 수행하게 됩니다.
teardown 시점에는 두 번째 경로가 열리는데, 이쪽은 대체 장치가 전혀 없습니다. waitForCommandBufferCompletions()가 사라지면서 wgpuInstanceRelease는 더 이상 처리 중인 buffer를 기다리지 않습니다. Ref<CommandBuffer>를 들고 있는 pending work item이 ~Instance()의 일부로 파괴되는 상황을 가정해 볼 수 있습니다. Instance::defaultScheduleWork fallback은 이런 항목들을 m_pendingWork에 추가합니다. 이 경우 마지막 release는 instance를 release한 thread가 어디든 그 thread에서 일어나게 됩니다.
실제로 invariant가 깨지는 지점은 destructor입니다. ~CommandBuffer()는 retainTimestampsForOneUpdateLoop()를 호출하는데, 여기서 m_commandEncoder가 지역 RefPtr로 복사됩니다. 그 뒤 RefPtr<CommandEncoder> 멤버가 파괴됩니다. atomic이 아닌 counter에 대한 이 ref()/deref()는 같은 counter를 건드리는 다른 동시 접근과 race 상태에 놓이게 됩니다. count가 다른 thread에서 0에 도달하면, ~CommandEncoder()도 그곳에서 실행되면서 m_device->removeCommandEncoder(m_uniqueId)를 호출하게 됩니다. 결과적으로 Device::m_commandEncoderMap이 createCommandEncoder()와 동시에 변경될 수 있습니다. 여기서 중요한 것은 RemoteDevice IPC 메시지를 처리하는 thread가 구체적으로 무엇이냐가 아닙니다. Metal의 completion thread와는 다른 thread라는 사실만으로 map 변경이 동시에 일어나기 때문입니다. 한편 ~CommandEncoder()는 finalizeBlitCommandEncoder()와 clearTracking()도 호출합니다. 두 함수 모두 Metal encoder 상태와 ObjC 컬렉션을 아무런 동기화 없이 건드립니다.
이 commit이 해당 위험을 새로 만들어낸 것은 아닙니다. 다만 commit된 CommandBuffer의 파괴 thread가 명시적인 owner와 release 시점의 drain으로 고정되지 않고, release 순서에 따라 결정되는 소유 형태로 되돌아간 셈입니다. 불리한 ordering에서의 결과는 non-atomic refcount 손상, 즉 조기 파괴나 use-after-free입니다. 여기에 GPU process 안에서 동기화 없는 hash table 변경까지 더해집니다.
Audit directions
- thread-safe 부모가 non-thread-safe 자식을 들고 있는 경우.
ThreadSafeRefCounted객체가 일반RefCounted객체에 대한RefPtr을 보유하면, 부모의 thread 자유도가 그것을 감당할 수 없는 자식에게 그대로 전가됩니다. 그리고 이 위반은 destructor에서만 실제로 드러납니다.Source/WebGPU/WebGPU/안의ThreadSafeRefCounted타입들을 골라 멤버를 나열하고,T가 일반RefCounted를 상속하는RefPtr<T>를 모두 표시해 보십시오. 검토할 때의 단서는 간단합니다. thread-safe base를 명시한 클래스 선언인데 멤버 목록에는 non-thread-safe smart pointer가 들어 있는 경우입니다. - 공유 컨테이너를 변경하는 destructor.
removeCommandEncoder에 도달하는 경로는~CommandEncoder()뿐입니다. 그래서 destructor를 실행하는 thread가 곧 map을 변경하는 thread가 됩니다. 결과적으로 map의 thread-safety는 map 자체 코드가 아니라 모든 release 지점의 성질에 좌우됩니다.Device가 소유한 다른 registry들도 destructor에서 도달 가능한 erase 호출이 있는지 점검하고, 각각에 대해 어떤 thread가 마지막 reference를 들 수 있는지 따져 보십시오. - teardown에서 제거된 drain. instance release 시점의 wait-for-completion 호출은, 타이밍에 좌우되던 소유 그래프를 매번 동일한 결과로 만들어 주는 유일한 장치인 경우가 많습니다. 이런 drain이 제거되면 앞으로 던져야 할 질문은 하나입니다. 아직 처리 중인 작업이 참조된 상태에서 파괴될 수 있는 pending 컨테이너가 어디인가입니다. 출발점으로는
Instance::defaultScheduleWork와m_pendingWork가 적당합니다.