intro_fiber
Introduction to fibers in c++
Introduction to fibers in c++
이 글은 Roman Gershman(로만 거쉬먼)이 작성한 "Introduction to fibers in c++"을 번역하고 약간의 내용을 추가했습니다. -> ⚛ 원문은 여기에 있습니다.
Seastar에 대한 내 게시물에서 우리는 연속 스타일 비동기 프로그래밍을 다루었습니다.
내 개인적인 의견은 이 스타일이 C++에서 사용하기 어렵다는 것입니다.
이 게시물에서는 대안적인 파이버 기반(Fiber-based) 접근 방식을 다루고 싶습니다.
모든 예제에서는 Boost.Fibers 라이브러리를 사용하겠습니다.
(Boost.Fibers 라이브러리의 자세한 설명은 여기를 보세요.)
파이버(Fibers)(녹색 스레드(green threads) 또는 협력 스레드(cooperative threads))는 일반 스레드와 유사하지만 몇 가지 중요한 차이점이 있습니다.
- 파이버는 시스템 스레드 간에 이동할 수 없으며 일반적으로 특정 스레드에 고정(pinned)됩니다.
⚛ Boost.Fibers 라이브러리에서 파이버는 스레드간 이동 가능하다. (참고 자료) - 파이버 스케줄러는 암시적 컨텍스트 전환을 수행하지 않습니다. 파이버는 활성 실행에 대한 제어를 명시적으로 포기해야 하며 스레드에서 동시에 하나의 파이버만 활성(실행)될 수 있습니다. 스위치는 활성 컨텍스트와 스택을 다른 파이버로 변경하여 수행됩니다. 이전 스택은 이전 파이버가 실행을 재개할 때까지 보존됩니다. 결과적으로 데이터 액세스가 단일 스레드인 한 비동기적이고 보호되지 않지만 안전한 코드를 작성할 수 있습니다.
- 파이버와 코루틴(coroutines)의 주요 차이점은 파이버에는 스케줄링 정책에 따라 다음에 호출될 활성 파이버를 결정하는 스케줄러가 필요하다는 것입니다.
반면 코루틴은 더 간단합니다. 코드의 지정된 지점으로 실행을 전달합니다.
⚛ 코루틴과 파이버의 차이점 (참고 자료) - 파이버는 사용자 영역 생물(creatures)이며 파이버 간의 생성 또는 컨텍스트 전환에는 커널이 포함되지 않으므로 스레드보다 효율적입니다(100배).
⚛ 스레드는 x86 CPU에서 수천 사이클인 반면 파이버는 100사이클 미만이다. (참고 자료) - 표준 스레드 차단 호출은 파이버를 차단하고 실제로 파이버의 가능한 모든 이점을 취소합니다.
📦 여러 파이버에서 데이터에 액세스합니다.
아래 예시 코드(스니펫)는 위의 2항 "파이버 스케줄러는 암시적 컨텍스트 전환을 수행하지 않습니다."을 보여줍니다.
두 파이버 모두 동일한 보호되지 않은(unprotected) 변수를 읽고-수정하고-씁니다(read-modify-write).
파이버 스케줄러는 파이버 생성 중 또는 join() 호출 중에 실행을 메인 파이버에서 fb1 또는 fb2로 전환할 수 있습니다.
이 경우 실행을 전환하지 않고 두 파이버를 명시적으로 시작했습니다.
즉, 메인 파이버는 fb1.join() 호출될 때까지 전달된 생성자를 계속 실행했습니다.
fb1.join() 호출 중에 메인 파이버는 fb1 실행이 완료될 때까지 자체를 일시 중단합니다.
그러나 다음에 어떤 파이버가 실행될지는 보장할 수 없습니다. fb1 또는 fb2 - 이는 스케줄러 정책에 따라 다릅니다.
어떤 경우든 생성된 각 파이버는 실행 함수 내부에 "인터럽트(interrupt)" 지점이 없기 때문에 완료될 때까지 차례로 실행됩니다.
결과적으로 'shared_state'는 순차적으로 변경됩니다.
이 예에서는 스케줄러 정책이 무엇인지 보여주지 않았으므로, 여기서는 'shared_state'에 적용된 변경 사항의 순서를 알 수 없습니다.
📦 스레드 차단 함수 호출은 파이버에 좋지 않습니다.
이 예시 코드(스니펫)는 위의 5항 "표준 스레드 차단 호출"을 보여줍니다.
⚛ ChatGPT: 코드 설명 (참고로 보세요)
여기서는 2개의 파이버를 시작하고 fb2에서 실행을 양보(전환)하여 fb1이 먼저 진행되도록 합니다.
fb1이 실행되기 시작하면 sleep()에 대한 직접 시스템 호출이 발생합니다.
sleep()은 우리의 파이버를 인식하지 못하는 OS 기능입니다. 즉, 사용자 영역입니다.
따라서 전체 스레드는 1초 동안 정지되고 fb1이 완료된 후에만 fb2가 다시 시작됩니다.
일반 동기화 기본 요소에도 동일한 사항이 적용됩니다.
⚛ ChatGPT: 코드 설명 (참고로 보세요)
여기서는 이전 예와 마찬가지로 fb2가 fb1이 먼저 실행을 시작하도록 허용(this_fiber::yield())합니다.
fb1은 차단하고 fb2가 신호를 보낼 때까지 기다립니다.
하지만 일반 뮤텍스와 조건 변수를 사용하기 때문에 컨텍스트 전환이 수행되지 않아 교착 상태(deadlock)가 발생합니다.
파이버 간의 조정(coordinate)을 위해서는 'boost.fibres'가 제공하는 파이버별 메커니즘(fiber-specific mechanisms)이 필요합니다.
fibers::mutex, fibers::conditional_variable, fibers::future<> 및 fibers::promise<>가 있습니다.
또한 'golang'의 채널과 유사한 (파이버) mpmc 차단 대기열 'fibers::buffered_channel'이 있습니다.
이러한 구성은 파이버가 정지된 시기를 인식하고 파이버 스케줄러를 호출하여 다음 활성 파이버로 재개합니다.
이를 통해 활성 파이버가 남아 있지 않고 I/O 또는 기타 신호에서 시스템이 차단될 때까지 실행 중인 스레드를 차단하지 않는 비동기 실행이 가능해집니다.
이는 I/O를 사용하거나 비동기 이벤트를 호출할 때마다 파이버 협력(fiber-cooperative) 코드를 사용해야 함을 의미합니다.
📦 Seastar comparison - 씨스타와 비교
⚛ ChatGPT: Seastar 모델 설명 (참고로 보세요)
⚛ Seastar - Asynchronous C++ framework
루프에서 미래형 작업(futurized action)을 반복하는 'Seastar'의 keep_doing 방법을 검토한 것을 기억하시나요?
파이버를 사용하면 훨씬 더 간단하고 친숙해집니다.
⚛ ChatGPT: fibers::mutex, fibers::conditional_variable, std::unique_lock 설명
⚛ fibers::mutex 설명(자료)
⚛ fibers::conditional_variable 설명(자료)
우리의 루프는 단지 일반 루프이지만 비동기 작업(action)은 아직 결과가 없으면 호출 파이버(fiber)를 차단해야 합니다.
'do_something_fiber_blocking'이 차단되면 실행이 다른 활성 파이버로 전환됩니다.
fb2가 활성 상태이고 아직 실행되지 않았을 수 있습니다.
이 경우 cv.wait 라인에서 실행되고 차단됩니다.
fb1은 반복할 때마다 fb2를 반복하고 깨웁니다.
그러나 조건이 충족될 때만 fb2는 'keep_going'을 false로 설정합니다.
keep_going 및 iteration 변수는 모두 보호되지 않습니다.
보시다시피 파이버 속성을 활용하는 한 파이버를 사용하여 비동기 코드를 작성하는 것이 더 간단합니다.
이전 게시물의 또 다른 예: 핸들러 객체를 사용하여 비동기적으로 파일 크기를 가져오는 것입니다.
이전 게시물에서와 마찬가지로, 호출 파이버를 10ms 동안 정지한 다음 IO 함수 f.size()를 호출합니다.
'Seastar'와 달리 흐름은 "sleep" 및 "f.size()"와 같은 모든 "지연(stalling)" 지점에서 스레드가 다른 실행 파이버로 "전환(switches)"된다는 차이점이 있는 일반 C++ 흐름입니다.
⚛ ChatGPT: "지연(stalling)" 설명
sleep()을 사용하면 프레임워크는 호출 파이버를 일시 중지하고 10밀리초 후에 자체적으로 재개하도록 스케줄러에 지시합니다.
f.size()를 사용하면 파일 클래스 개발자는 해당 파일 메타데이터를 가져오고 호출이 완료되면 파이버를 다시 깨워야 하는 IO 장치에 대한 비동기 호출 시 파이버를 일시 중단할 책임이 있습니다.
기본 IO 인터페이스가 완료 콜백을 허용하는 한 fibers::mutex 및 fibers::conditional_variable 구성을 사용하여 동기화를 쉽게 수행할 수 있습니다.
파이버 기반(fiber-based) 코드는 미래 기반(futures-based) 코드보다 간단하며 개체 소유권 문제가 덜 번거롭습니다.
호출 파이버는 클래식 프로그래밍 모델과 마찬가지로 호출 스택(call-stack)을 유지하므로 비동기 작업이 완료되기 전에 파일 개체가 범위를 벗어나지 않습니다.
Seastar 모델은 무한한 실행 스레드를 가정하며 동기 연속성을 원하는 경우에도 연속 작업을 동기화하기 위해 연속(continuations)을 사용해야 합니다.
Fibers 모델을 사용하면 기본적으로 동기식 흐름을 가정하고, 병렬 실행을 생성하려면 새 Fiber를 시작해야 합니다.
따라서 Fiber와의 병렬성 양은 실행된 Fiber 수에 따라 제어되는 반면 Continuations Model은 'futures'와 'continuations'의 종속성 그래프(dependency graph)를 구축하여 무한히 큰 병렬성을 제공합니다.
매우 정교한(sophisticated) 비동기 처리의 경우에도 대부분의 흐름은 많은 비동기 작업을 병렬로 시작(launch)하려는 몇 개의 분기에서만 동기적으로 보인다고 생각합니다.
따라서 저는 Fiber 기반 모델이 특히 다양한 언어 제한과 자동 가비지 수집 기능의 부족으로 인해 일반적으로 C++에서 더 편리하다고 생각합니다.
Seastar는 동일한 후드(hood)에서 RPC, HTTP, 네트워킹 및 이벤트 인터페이스뿐만 아니라 비동기 실행을 위한 높은 수준의 프레임워크를 제공하기 때문에 Boost.fibres를 'Seastar'와 비교하는 것은 전적으로 공평하지 않습니다.
반면 Boost.Fibers는 Fiber에만 초점을 맞춘 하위 수준 라이브러리입니다.
Boost.Asio 및 Boost.Fibers는 Seastar와 다소 유사한 기능을 제공할 수 있습니다.
불행하게도 이러한 라이브러리를 함께 효율적으로 사용하는 방법에 대한 자료는 인터넷에 많지 않습니다.
이것이 바로 제가 고성능 백엔드를 구축하기 위한 높은 수준의 메커니즘을 제공하는 GAIA 프레임워크를 출시한 이유입니다.
다음 포스팅에서는 GAIA에 대해 계속해서 이야기하겠습니다.