Mula sa C Hacks hanggang sa C++20: Ebolusyon ng mga Coroutine

Huling pag-update: 12/27/2025
May-akda: C SourceTrail
  • Ginagawang pangkalahatan ng mga coroutine ang mga subroutine sa pamamagitan ng pagpapanatili ng lokal na estado at pagpapatuloy ng pagpapatupad sa mga punto ng suspensyon, na nagbibigay-daan sa natural na pagpapahayag ng mga state machine, generator at kooperatibong concurrency.
  • Ang mga implementasyon ng C ay umunlad mula sa manu-manong pagmamanipula ng stack at mga POSIX context API patungo sa mga macro-based na pagtatantya at mga portable coroutine libraries na binuo sa user-level context switching.
  • Istandardisa ng C++20 ang isang stackless coroutine model na may mga pangako, co_await, co_yield at mga coroutine frame, na nagpapahintulot sa mga library na tukuyin ang mga high-level na async at generator abstraction.
  • Ang standardized na modelo, na sinamahan ng mga waiter at custom na uri ng pangako, ay pinag-iisa ang paggamit ng coroutine sa iba't ibang library habang pinapanatili ang mahuhulaang pagganap at kontrol.

ebolusyon ng mga coroutine mula C patungong C++

Ang mga Coroutine ay nasa isang kamangha-manghang gitnang lugar sa pagitan ng mga klasikong function at mga ganap na thread, at ang kanilang kwento mula sa mga low-level na C trick hanggang sa standardized na suporta sa wika ng C++20 ay isa sa mga pinaka-kawili-wiling ebolusyon sa modernong systems programming. Kung nasubukan mo nang pagsabayin ang mga callback, state machine, at thread synchronization para lamang mahawakan ang non-blocking I/O, naranasan mo na ang uri ng sakit na idinisenyo para maibsan ng mga coroutine.

Sa artikulong ito, tatalakayin natin kung paano umunlad ang mga coroutine mula sa mga hand-crafted na C hack at POSIX context API patungo sa high-level, stackless C++20 coroutine model. pagpapaliwanag kung ano talaga ang isang coroutine, kung paano ito naiiba sa mga generator, thread at fiber, kung ano ang ibig sabihin ng "stackful" vs "stackless", at kung paano ginagamit ang makinarya ng C++20 (mga promise object, mga hawakan ng coroutine, co_await, co_yield, co_return) ay talagang kumikilos nang palihim.

Ano ba talaga ang coroutine?

katangian at malalim na csharp
Kaugnay na artikulo:
Malalim na mga tampok, ecosystem, at tooling ng C#

Walang iisang pangkalahatang tinatanggap na pormal na kahulugan ng coroutine, ngunit ang literatura ay nagtatagpo sa dalawang pangunahing katangian na nagpapaiba sa mga coroutine mula sa mga ordinaryong subroutine:

  • Nakaligtas ang lokal na estado sa kabila ng mga suspensyon: Ang datos na lokal sa isang coroutine ay nananatili sa pagitan ng mga pag-activate, kaya ang bawat instance ng coroutine ay kumikilos na parang isang object na may memorya.
  • Maaaring ihinto ang pagpapatupad at ipagpatuloy ito sa ibang pagkakataon mula sa parehong punto: Kapag umalis ang kontrol sa isang coroutine, maaari itong muling ipasok sa eksaktong punto ng suspensyon sa halip na palaging magsimula sa itaas tulad ng isang normal na function.

Sa halip na magkaroon ng iisang, one-shot na entry at exit tulad ng mga subroutine, sinusuportahan ng mga coroutine ang maraming entry at exit point sa buong buhay ng mga ito, na ginagawa silang makapangyarihan para sa pagpapahayag ng mga producer, consumer, state machine, cooperative scheduler at asynchronous workflow sa isang linear at nababasang istilo.

Mga pangunahing sukat ng disenyo ng coroutine

Ang mga sistema ng coroutine sa totoong mundo ay nag-iiba-iba sa tatlong mahahalagang aksis na tumutukoy kung paano sila kumikilos at kung gaano sila ka-expressive: ang control-transfer model, kung ang mga coroutine ay mga first-class value, at kung ang mga ito ay stackful o stackless.

Una, ang mekanismo ng control-transfer ay naghihiwalay sa asymmetric mula sa symmetric coroutines. Sa isang asimetrikong disenyo, ang aktibong coroutine ay maaari lamang magbigay pabalik sa direktang tumatawag nito (gamit ang isang operasyon na may konseptong katulad ng yield), at ipagpapatuloy ito ng tumatawag mamaya (na may operasyong katulad ng resume). Sa mga simetrikong disenyo, maaaring tahasang ilipat ng isang coroutine ang kontrol sa anumang ibang coroutine, sa halip na palaging bumalik sa sinumang tumawag dito.

Pangalawa, tinatrato ng ilang wika ang mga coroutine instance bilang mga primera klaseng bagay na maaari mong iimbak, ipasa-pasa, at manipulahin nang malaya, habang ang iba ay inilalantad lamang ang mga coroutine bilang mga syntactic construct na may limitadong paraan upang makipag-ugnayan sa mga ito. Ang primera klaseng suporta ay lubhang nagpapataas ng flexibility at composability.

Pangatlo, ang mga coroutine ay maaaring stackful o stackless. Ang isang stackful coroutine ay maaaring mag-suspinde nang malalim sa loob ng isang nested call stack; kapag ito ay nagpatuloy, ang bawat frame sa stack na iyon ay magpapatuloy kung saan ito tumigil. Ang mga stackless coroutine ay mag-suspinde lamang sa antas ng mismong coroutine function: ang mga regular na helper function ay hindi maaaring mag-yield maliban kung ang mga ito mismo ay mga coroutine o espesyal na may anotasyon.

Ang terminong "full coroutine" ay iminungkahi para sa pinaka-nagpapahayag na kumbinasyon: isang stackful, first-class coroutine, simetriko man o asimetriko, na sapat ang lakas upang ipahayag ang mga one-shot continuation o delimited continuation. Kahit na ang mga simetriko at asimetrikong istilo ay may katumbas na kapangyarihang magpahayag, ang asimetrikong modelo ay kadalasang mas parang 'routine-like' at pamilyar sa karamihan ng mga programmer.

Mga subroutine, coroutine, generator at thread

Ang mga subroutine ay maaaring ituring na isang espesyal na kaso ng mga coroutine na may lubhang pinaghihigpitang daloy ng kontrol at pag-uugali ng estado. Ang isang normal na function ay laging nagsisimula sa unang instruksyon nito, lumalabas nang isang beses, at itinatapon ang lokal na estado nito pagkatapos. Sa kabilang banda, ang isang coroutine ay maaaring maglipat ng kontrol sa iba pang mga coroutine, maipagpatuloy sa ibang pagkakataon sa yield point, at mapanatili ang estado nito sa mga paglilipat na ito. Maraming mga instance ng coroutine ng parehong function ang maaaring magsabay, bawat isa ay may sarili nitong napreserbang lokal na data.

Ang mga generator ay bumubuo ng isang kilalang subset ng mga coroutine, na minsan ay tinatawag na "semicoroutine". Tulad ng mga coroutine, maaaring ihinto ng mga generator ang pagpapatupad nang maraming beses at magpapatuloy sa ibang pagkakataon, ngunit palagi silang babalik sa kanilang direktang tumatawag at walang paraan upang i-redirect ang pagpapatupad sa isang arbitraryong ikatlong coroutine. Sinadya ang limitasyong iyon: ang mga generator ay na-optimize para sa pagpapatupad ng mga iterator at lazy sequence kung saan ang bawat isa yield ang ibig sabihin ay "magdulot ng halaga para sa sinumang umuulit sa akin".

Sa katunayan, maaari mong gayahin ang mga pangkalahatang coroutine sa pamamagitan ng pagpapatong ng isang dispatcher sa ibabaw ng isang generator system, halimbawa sa pamamagitan ng pagkakaroon ng isang top-level na trampoline na tumatanggap ng mga token mula sa mga generator at nagpapasya kung aling generator ang susunod na ia-activate. Ang pamamaraang ito ay dating ginagamit sa mga wikang tulad ng mga naunang bersyon ng Python, na mayroon lamang mga generator ngunit walang built-in na coroutine primitives.

Ang mga coroutine ay kadalasang inihahambing sa mga thread, ngunit ang mga ito ay pangunahing tungkol sa kooperatibong pag-iiskedyul sa halip na preemptive parallelism. Ang mga coroutine ay nagbibigay ng concurrency sa diwa ng pag-interleave ng mga gawain nang hindi binabago ang pangkalahatang semantika, ngunit hindi sila tumatakbo nang sabay-sabay sa maraming core nang mag-isa. Ang isang coroutine ay nagbibigay lamang ng kontrol sa mga tahasang suspension point, kaya ang code sa pagitan ng mga puntong iyon ay tumatakbo nang walang pagkaantala mula sa iba pang mga coroutine.

Tinatanggal ng kooperatibong modelong ito ang maraming problema sa synchronization na karaniwan sa mga thread: dahil iisang coroutine lang ang tumatakbo sa isang pagkakataon sa isang partikular na scheduler, madalas ay hindi mo na kailangan ng mga mutex o atomic operation para sa ordinaryong shared state. Sa kabilang banda, ang mga coroutine mismo ay hindi mag-e-exploit ng maraming CPU core maliban kung pagsamahin mo ang mga ito sa mga thread o isang multi-threaded executor.

Halimbawa ng klasikong coroutine: prodyuser-konsyumer

Isang halimbawa ng simetrikong mga coroutine sa aklat-aralin ang pattern ng prodyuser-konsyumer na may nakabahaging pila. Ang isang coroutine ay bubuo ng mga item at itutulak ang mga ito sa isang pila hanggang sa mapuno ito, pagkatapos ay ibibigay sa konsyumer; ilalagay ng konsyumer ang mga item hanggang sa mawalan ng laman ang pila, pagkatapos ay ibabalik sa prodyuser. Ang pagpapatupad ay pabalik-balik habang ang bawat panig ay nagtutulungang nagbibigay ng kontrol.

Sa ganitong implementasyon, ang prodyuser at mamimili ay tila tumatakbo nang "magkasabay" mula sa pananaw ng programmer, bagama't sa katunayan ay tumatalon lamang sila pabalik-balik sa loob ng iisang thread ng pagpapatupad. Hindi na kailangan ng mga thread sa antas ng OS o mga context switch: ang yield operation ay maaaring maging isang low-level jump na nagre-rewire sa aktibong stack frame.

Ang halimbawang ito ay madalas na ginagamit upang ipakilala ang multithreading, ngunit mahalagang tandaan na ang mga coroutine lamang ay sapat na upang ipahayag ang lohika, at ang pagpapalit ng mga ito ng mga thread ay maaaring hindi kinakailangan o maging mapanganib sa mga kapaligirang nagmamalasakit sa mga garantiya sa real-time o kaunting runtime overhead.

Bakit mahalaga ang mga coroutine: mga state machine, aktor at async workflow

Dahil pinapanatili ng mga coroutine ang kanilang execution point at ang kanilang mga lokal na variable sa iba't ibang yield, nagbibigay ang mga ito ng isang natural na paraan upang ipatupad ang mga kumplikadong state machine nang walang malawak na switch statement, flag o tahasang program counter. Ang kasalukuyang suspension point ay literal na kumakatawan sa kasalukuyang estado.

Ang mga coroutine ay angkop din sa mga modelong concurrency na istilo-aktor, tulad ng mga ginagamit sa maraming game engine. Ang bawat aktor ay maaaring ipatupad bilang isang coroutine na pana-panahong nagbibigay ng kontrol pabalik sa isang sentral na scheduler, na nagpapatakbo ng isang aktor pagkatapos ng isa pa sa isang thread. Ang kooperatibong multitasking na ito ay nag-aalis ng pangangailangan para sa karamihan ng pag-lock habang nagbibigay pa rin ng tumutugong pag-uugali.

Ang mga generator na binuo gamit ang mga coroutine ay mainam para sa pagtatrabaho sa mga stream at data structure traversal, lalo na kung gusto mo ng tamad at on-demand na produksyon ng mga value. Sa halip na itulak ang mga value sa isang consumer, hinahayaan ng generator ang consumer na hilahin ang mga value nang paisa-isa gamit ang isang simpleng loop.

Ang mga coroutine ay nagsisilbing sikat din sa mga pattern ng komunikasyon tulad ng mga pipeline at communicating sequential processes (CSP), kung saan ang bawat yugto ay isang coroutine na nag-a-yield kapag naghihintay ito ng input o output. Pagkatapos ay ipinagpapatuloy ng isang scheduler ang mga coroutine kapag handa na ang kanilang mga channel ng komunikasyon, na nagbibigay ng isang eleganteng alternatibo sa mga loop ng kaganapan na maraming callback.

Panghuli, maraming numerical libraries ang gumagamit ng istilo na minsan ay tinatawag na "reverse communication," kung saan ang isang solver ay sinuspinde ang sarili nito tuwing kailangan nito ang user na magbigay ng ilang ebalwasyon ng function, pagkatapos ay nagpapatuloy kapag tumugon na ang user. Ang mga Coroutine ay nagbibigay ng direkta at nababasang paraan upang ipahayag ang pabalik-balik na daloy ng kontrol.

Mula sa mga low-level na implementasyon ng C hanggang sa mga portable na library

Ang isang klase ng mga implementasyon ay manu-manong kumukuha ng pangalawang call stack at pagkatapos ay ginagamit setjmp/longjmp para lumipat sa pagitan ng mga coroutine. Ang platform-specific inline assembly ay maaaring mag-set up ng isang bagong stack para sa bawat coroutine; sa mga POSIX system, ang mga signal na sinamahan ng sigaltstack maaaring gamitin upang i-bootstrap ang pagpapatupad sa isang alternatibong stack sa purong C. Kapag ang bawat coroutine ay may sariling stack, setjmp sine-save ang estado ng CPU at stack pointer, at longjmp ibinabalik ang mga ito upang ipagpatuloy ang coroutine.

Ang ilang POSIX at UNIX-aligned C libraries na historically exposed helper functions tulad ng getcontext, setcontext, makecontext at swapcontext, na direktang sumasaklaw sa ideya ng paglipat sa pagitan ng mga konteksto sa antas ng gumagamit. Bagama't minarkahan na ang mga ito bilang lipas na sa POSIX.1-2008, nabuo nila ang gulugod ng ilang mga coroutine library at nagbigay inspirasyon sa mga susunod na disenyo.

Minimal na pag-bypass sa mga implementasyon ng coroutine setjmp/longjmp at mga context API nang buo, sa halip ay pumipili ng sulat-kamay na assembly na nagpapalit lamang ng program counter at stack pointer, na sumisira sa iba pang mga register. Maaari itong maging mas mabilis sa ilang ABI dahil sine-save nito ang eksaktong kailangan at wala nang iba, samantalang setjmp kailangang maingat na mag-imbak ng mas malaking hanay ng mga rehistro.

Upang maitago ang lahat ng kasalimuotan na ito mula sa application code, maraming C library ang lumitaw sa mga nakaraang taon na nag-package ng coroutine na lumilipat sa mga malinis na API, tulad ng kay Russ Cox libtask at iba't ibang iba pa (libpcl, coro, lthread, libcoro, libaco, libco at marami pang iba). Ang mga library na ito ay karaniwang nagbibigay ng mga abstraksyon tulad ng mga magaan na gawain o mga fiber na maaaring ipagpatuloy at ibigay nang hindi kinakailangang mag-alala ang tumatawag tungkol sa mga pinagbabatayan na trick sa pag-assemble.

Tinatayang mga coroutine sa C gamit ang mga macro

Kung saan walang magagamit o hindi kanais-nais na mga hiwalay na stack o context-switching API, tinatantya rin ng mga developer ang mga coroutine sa purong C gamit ang mga macro at switch statement, isang pamamaraan na sikat na naidokumento ni Simon Tatham at nauugnay sa klasikong trick na "Duff's device".

Ang pangunahing ideya ay i-encode ang estado ng coroutine bilang isang program counter na ipinatupad gamit ang isang switch at case mga label, kung saan ang bawat isa yieldAng macro na parang -ay lumalawak sa code na nagtatala ng kasalukuyang label sa isang static variable at pagkatapos ay bumabalik sa tumatawag. Sa susunod na tawag, ang function ay babalik sa label na iyon sa halip na magsimula sa simula.

Ang mga library tulad ng Protothreads ay gumagamit ng pattern na ito upang makapagbigay ng napakagaan at walang stack na mga coroutine na akma sa mga naka-embed na kapaligiran, ngunit ang pamamaraan ay may kasamang malubhang limitasyon: ang mga lokal na variable ay hindi natural na nananatili sa mga yield maliban kung ang mga ito ay nakaimbak sa mga static o panlabas na istruktura, hindi mo madaling masususpinde mula sa mga nested function call, at sa pangkalahatan ay mayroon ka lamang isang entry point.

Kahit ang mga tagapagtaguyod nito ay inilalarawan ang macro-based na panlilinlang na ito bilang ilan sa mga pinakapangit na C code na ginamit sa produksyon, at itinuturo ng mga kritiko na ang nagreresultang daloy ng kontrol ay maaaring mahirap isaalang-alang at mapanatili sa paglipas ng panahon. Gayunpaman, nananatili itong isang kapaki-pakinabang na kompromiso sa mga sistema kung saan hindi katanggap-tanggap ang mga karagdagang stack o linker trick.

Batong panulukan: mga hibla, sinulid at mga kaugnay na abstraksyon

Sa mga mainstream na kapaligiran na walang mga katutubong coroutine, ang mga thread (at, sa mas mababang antas, mga fiber) ay naging default na bloke ng pagbuo para sa concurrency, kahit na sapat na ang kooperatibong pag-uugali. Ang mga thread ay kadalasang mahusay na sinusuportahan at mahusay na dokumentado, ngunit nilulutas nito ang isang mas malawak at mas kumplikadong problema kaysa sa aktwal na kailangan ng karamihan sa mga kaso ng paggamit ng coroutine.

Ang mga fiber, kung saan mayroon, ay nagpapakita ng mas malapit na tugma sa mga user-level coroutine dahil ang mga ito ay naka-iskedyul nang magkakasama at maaaring ilipat nang walang kasangkot na OS, na ginagawa silang isang natural na substrate kung saan ipatupad ang mga coroutine-style API. Gayunpaman, ang suporta ng system para sa mga fiber ay hindi pantay-pantay kumpara sa mga thread, at ang kadalian ng pagdadala ay naapektuhan.

Isang kapansin-pansing pagkakaiba sa pagitan ng mga thread at coroutine ay ang pag-iiskedyul ng pag-uugali. Ang mga thread ay karaniwang nauuna sa mga arbitraryong punto, na pumipilit sa mga programmer na mangatwiran tungkol sa mga kondisyon ng karera at pag-synchronize sa lahat ng dako. Sa kabilang banda, ang mga Coroutine ay nagbabago lamang ng kontrol sa mga tahasang punto ng suspensyon, na kadalasang nagbibigay-daan sa iyong magsulat ng mas simpleng code nang walang mga lock o atomic na operasyon.

Maraming landas na ang ginalugad ng mga wika at runtime upang gayahin ang mga coroutine sa ibabaw ng umiiral na imprastraktura, mula sa muling pagsusulat ng bytecode (tulad ng sa ilang Java coroutine frameworks) hanggang sa pagmamapa ng mga coroutine-like construct sa mga iterator (tulad ng ginawa ng C# sa yield bago async/await) o pagbuo ng mga ito sa ibabaw ng mga berdeng sinulid, mga pagpapatuloy o mga hibla.

Mga Coroutine sa iba't ibang wika ng programming

Sa paglipas ng mga dekada, maraming wika ang nag-eksperimento sa mga construct na parang coroutine, bawat isa ay may kanya-kanyang lasa at mga kompromiso, at ang pag-unawa sa ecosystem na ito ay nakakatulong na mailagay ang ebolusyon ng C++ sa konteksto.

Ang ilang mga wika ay nag-aalok ng primera klaseng, stackful coroutine nang direkta sa runtime at karaniwang library. Halimbawa, ang Lua ay sumusuporta sa asymmetric, stackful coroutines simula noong bersyon 5.0 sa pamamagitan ng pamantayan nito. coroutine API, na may mga primitive para sa paglikha, pagpapatuloy, at pag-yield. Ang Modula-2 ay dating kinabibilangan ng suporta sa coroutine sa pamamagitan ng mga pamamaraan tulad ng NEWPROCESS at TRANSFER na nagse-set up ng magkakahiwalay na stack at nagpapalipat-lipat sa pagitan ng mga konteksto.

Ang ibang mga ecosystem ay nagtayo ng mga coroutine sa ibabaw ng mga umiiral na primitive tulad ng mga continuation o green thread. Ang Racket (at mga Scheme dialect sa pangkalahatan) ay maaaring magpatupad ng mga coroutine nang halos walang kahirap-hirap dahil inilalantad nila ang mga continuation bilang mga first-class na value. Ang mga Smalltalk system, kung saan ang mga execution stack ay mga manipulable object, ay maaari ring mag-host ng mga coroutine abstraction nang walang karagdagang suporta sa VM. Sa OCaml, ang cooperative concurrency ay ibinigay sa pamamagitan ng mga module na nag-iiskedyul ng mga thread nang maaga sa isang OS thread, habang ang mga mas bagong bersyon ay nagdaragdag ng suporta sa istilo ng green-thread.

Ang mga wikang nakatuon sa asynchronous programming ay kadalasang nagsisimula sa mga generator bago ipakilala ang mga full coroutine. Sa simula, idinagdag ng C# ang mga generator sa pamamagitan ng yield at ang pattern ng iterator, pagkatapos ay umunlad sa async/await upang imodelo ang mga asynchronous na operasyon bilang mga coroutine. Sinundan ng JavaScript ang katulad na landas: Ipinakilala ng ES2015 ang mga generator bilang isang espesyal na kaso ng mga coroutine, at idinagdag ang mga mas bagong bersyon async/await itinayo sa ibabaw ng mga pangako at mga generator.

Sa mundo ng JVM, ang Java mismo ay hindi nag-aalok ng mga katutubong coroutine, ngunit pinupunan ito ng mga tool at wika sa paligid nito. Binabago ng ilang library ang bytecode upang gayahin ang pag-uugali ng coroutine, ang iba ay gumagamit ng JNI upang ma-access ang mga mekanismo na partikular sa platform, at ang ilan ay umaasa sa mga thread upang gayahin ang semantika ng coroutine sa mas mataas na gastos. Sa kabilang banda, ang Kotlin ay nagbibigay ng mga coroutine bilang isang tampok ng first-party library at maaaring makipag-ugnayan sa Java code (bagaman hindi natural na "magsuspinde" ang Java at sa halip ay dapat na harangan o gamitin ang mga futures).

Ang scripting at mga dynamic na wika ay gumamit ng iba't ibang pamamaraan. Nagsimula ang Python sa mga pinahusay na generator (PEP 342), pinalawak ang mga ito gamit ang subgenerator delegation (PEP 380), at kalaunan ay ipinakilala ang mga tahasang katutubong coroutine na may async/await (PEP 492), kalaunan ay inireserba ang mga keyword na iyon sa Python 3.7. Ipinapatupad ng Ruby ang mala-coroutine na pag-uugali sa pamamagitan ng mga fibers; Nag-aalok ang Raku at Tcl ng mga katutubong coroutine construct; Nagdagdag ang PHP 8.1 ng mga fibers upang suportahan ang mga coroutine-based na library para sa asynchronous I/O.

Sinusuri rin ng mga system-oriented na wika ang mga modelong parang coroutine na may sarili nilang twist. Gumagamit ang Go ng mga goroutines — magaan at multiplexed na proseso na may mga stack na dynamically-sized. Bagama't ang mga goroutines ay hindi coroutines sa mahigpit na kahulugan (mas malapit ang mga ito sa mga green thread, at ang lokal na data ay hindi nakakayanan ang maraming 'tawag' sa kahulugan ng coroutines), sumasakop ang mga ito sa isang katulad na mental space gaya ng mga gawain sa antas ng user na pinamamahalaan ng isang runtime scheduler. Inilalantad ng D ang mga coroutines sa pamamagitan ng Fiber sa karaniwang library nito, at binabalot ito ng ilang framework sa mga maginhawang interface na istilo-generator.

Ipasok ang mga aklatan ng C++ bago ang pamantayan

Bago ang mga standardized na coroutine ng C++, ang ecosystem ay umasa sa mga third-party library upang magdala ng coroutine semantics sa wika, gamit ang pinaghalong assembler context switching, mga platform API, at matalinong template metaprogramming.

Ang Boost.Context ay lumitaw bilang isang mababang antas na pundasyon para sa pagpapalit ng mga konteksto ng pagpapatupad sa maraming arkitektura at operating system, na nagbibigay ng isang portable na paraan upang manipulahin ang mga stack ng user-space. Bukod pa rito, ang Boost.Coroutine at kalaunan ang Boost.Coroutine2 ay nag-alok ng mas mataas na antas na mga abstraction ng coroutine, na lumilipat mula sa suporta para sa parehong simetriko at asimetrikong mga anyo patungo sa isang mas modernong asimetrikong interface na mas mahusay na nakahanay sa mga kontemporaryong idyoma ng C++.

Ginalugad ng iba pang mga proyekto ang iba't ibang anggulo, tulad ng mga stackless coroutine na nakabatay sa preprocessor na ginagaya ang await/yield semantika, mga single-header library na bumabalot sa mga platform fiber, o mga framework (tulad ng Mordor o Oat++ coroutine) na partikular na nakatuon sa pagtatago ng mga asynchronous I/O callback sa likod ng coroutine-like sequential code.

Ipinakita ng mga ecosystem na ito na ang mga C++ developer ay sabik sa mala-coroutine na pagpapahayag, ngunit isiniwalat din nila ang mga problemang dulot ng mga ad-hoc na solusyon: hindi pare-parehong syntax, mga problema sa madaling dalhin, mahirap na pag-debug at paggamit ng mga tool, at hindi gaanong mahalagang integrasyon sa iba pang bahagi ng karaniwang library.

C++20 coroutines: isang standardized, stackless na modelo

Sa wakas ay dinala ng C++20 ang mga coroutine sa wika bilang isang pangunahing tampok, ngunit may sadyang minimal at mababang antas na disenyo. Sa halip na magpadala ng isang partikular na mataas na antas na abstraksyon (tulad ng "task", "generator" o "future") sa karaniwang library, isinaayos ng C++20 ang mga bloke ng gusali na nagpapahintulot sa mga library na tukuyin ang kanilang sariling mga uri na angkop para sa coroutine.

Ang isang function ay nagiging isang coroutine kung ang katawan nito ay naglalaman ng alinman sa mga construct na partikular sa coroutine: ang co_await isuspinde ng operator hanggang sa maging handa ang ilang kaganapan o halaga, ang co_yield ekspresyon upang makabuo ng isang halaga at magsuspinde (tulad ng sa mga generator), o ang co_return pahayag upang makumpleto ang coroutine, opsyonal na may kasamang resulta.

Kapag natukoy na ng compiler ang alinman sa mga construct na ito, binabago nito ang function sa isang state machine na ang persistent state ay nakaimbak sa isang heap-allocated na "coroutine frame", maliban na lang kung mapatunayan ng optimization na ang lifetime ng frame ay mahigpit na naka-nest sa loob ng caller at maaaring i-embed sa stack frame ng caller. Hawak ng frame na ito ang promise object, mga kopya ng mga argument, mga lokal na variable na matatagpuan sa mga suspension point, at bookkeeping metadata para sa pagpapatuloy ng execution.

Mahalaga, ang mga C++20 coroutine ay walang stack: Maaari lamang magsuspinde ang isang coroutine sa mga tahasang punto ng suspensyon (co_await or co_yield) at hindi maaaring lantaran na magbunga mula sa loob ng mga arbitraryong nested call maliban kung ang mga function na iyon ay mga coroutine din o kung hindi man ay kasali sa makinarya ng coroutine. Ginagawa nitong mas simple at mas mahuhulaan ang implementasyon, sa kapalit ng ilang kapangyarihang nagpapahayag kumpara sa mga ganap na stackful na disenyo.

Mga paghihigpit at lifecycle ng isang C++20 coroutine

Hindi lahat ng function sa C++20 ay pinapayagang maging isang coroutine; ang pamantayan ay nagpapataw ng ilang mga limitasyon upang mapanatiling maayos ang modelo. Hindi maaaring maging mga coroutine constexpr or consteval mga tungkulin, hindi sila maaaring maging mga konstruktor, mga destruktor, o ang main function, at maaaring hindi sila gumamit ng mga C-style na variable argument o mga placeholder return type tulad ng plain auto nang walang karagdagang detalye.

Kapag unang tinawag ang isang coroutine, hindi ito agad kumikilos na parang isang normal na function body. Sa halip, ang prologo na binuo ng compiler ay naglalaan ng coroutine frame (karaniwan ay sa pamamagitan ng operator new), kinokopya ang mga parameter ng function sa frame na iyon (ayon sa halaga o sa pamamagitan ng sanggunian gaya ng idineklara), binubuo ang promise object at pagkatapos ay tinatawag ang promise.get_return_object(), na karaniwang nagbubunga ng ilang bagay na handle o wrapper na ibinabalik sa tumatawag.

Ang promise object ay isang uri na tinukoy ng gumagamit, na natuklasan sa pamamagitan ng std::coroutine_traits batay sa uri ng pagbabalik at listahan ng parameter ng coroutine, at idinidikta nito kung paano gumagana ang mga resulta, eksepsiyon, at mga patakaran sa suspensyon. Nahihinuha ng compiler ang isang Promise uri at pagkatapos ay tatawagin ang mga pamamaraan tulad ng initial_suspend(), final_suspend(), return_value() or return_void(), at unhandled_exception() sa mga naaangkop na yugto sa siklo ng buhay ng coroutine.

Sa simula ng pagpapatupad, tatawag ang coroutine promise.initial_suspend() at co_awaitkahit ano pa man ang ibalik niyan, na nagpapahintulot sa mga may-akda ng aklatan na magpasya kung ang kanilang uri ng coroutine ay "sabik" (nagsisimulang tumakbo kaagad) o "tamad" (bumabalik sa tumatawag hanggang sa tahasang ipagpatuloy). Kapag ang coroutine ay tuluyang natapos sa pamamagitan ng co_return o isang hindi nahawakang eksepsiyon, tinatawag nito promise.final_suspend(), na nagbibigay sa library ng huling pagkakataon para mag-iskedyul ng mga pagpapatuloy o paglilinis.

Kapag ang coroutine frame ay nasira — pagkatapos makumpleto o sa pamamagitan ng isang tahasang operasyon ng pagsira sa handle nito — Sinisira ng runtime ang promise object, ang mga kopya ng mga parameter at anumang natitirang live locals, pagkatapos ay pinapalaya ang memorya gamit ang operator delete (o may kasamang promise-specific allocator kung mayroon). Kung mabigo ang alokasyon at ang pangako ay tumutukoy get_return_object_on_allocation_failure(), ang coroutine ay maaaring magpahiwatig ng pagkabigo nang may kagandahang-loob nang hindi nagkakalat std::bad_alloc.

co_await, mga hinihintay at mga naghihintay

Ang co_await Ang operator ay ang pangunahing suspension primitive sa coroutine system ng C++20, at ang pag-unawa sa mekaniks nito ay mahalaga para sa pagdidisenyo ng matatag na asynchronous abstraktions.

Kapag sumulat ka co_await expr; sa loob ng isang coroutine, unang kino-convert ng compiler expr sa isang bagay na "hinihintay", o sa pamamagitan ng pagdaan nito promise.await_transform(expr) kung umiiral ang naturang miyembro, o sa pamamagitan ng paggamit nito nang walang pagbabago. Pagkatapos ay tinutukoy nito ang object na "waiter" sa pamamagitan ng pagtawag sa isang miyembro operator co_await sa hinihintay, isang hindi miyembro operator co_await, o simpleng ituring ang awaitable mismo bilang waiter kung walang ganitong operator.

Ang waiter ay dapat magbigay ng tatlong pangunahing operasyon: await_ready(), await_suspend(handle) at await_resume(). If await_ready() Nagbabalik ng true, hindi sinuspinde ng coroutine at direktang tinatawag await_resume(), na nagbibigay-daan sa mabibilis na landas para sa mga operasyong nakumpleto na. Kung ito ay magbabalik ng false, ang coroutine ay sususpindihin, ang estado nito ay itatago sa frame, at await_suspend() ay tinatawag gamit ang isang handle sa kasalukuyang coroutine.

Loob await_suspend(), maaaring magpasya ang waiter kung ano ang gagawin sa coroutine handle: iiskedyul ito para sa pagpapatuloy sa ibang pagkakataon sa ilang tagapagpatupad, ipagpatuloy ang isa pang coroutine, o ipagpatuloy pa ang parehong coroutine kaagad (depende sa uri ng pagbabalik at halaga ng await_suspend()). Kapag natapos na ang hinihintay na operasyon, may tatawag kalaunan handle.resume(), kung saan ang kontrol ay babalik sa bago await_resume(), At pagkatapos ay await_resume() nagbubunga ng resulta ng co_await pagpapahayag.

Ang karaniwang aklatan ay nagpapadala ng dalawang simpleng hinihintay na bagay: std::suspend_always at std::suspend_never, na kadalasang ginagamit sa initial_suspend() at final_suspend() mga implementasyon upang ipahiwatig ang tamad o sabik na pagsisimula at kung paano kumilos sa dulo. Ang mas sopistikadong mga waiter ay maaaring humawak ng estado ng bawat operasyon, halimbawa upang itali ang mga coroutine sa mga asynchronous I/O API, at ang estadong iyon ay naninirahan sa loob ng frame ng coroutine sa kabila ng suspension point.

co_yield at mga coroutine na istilong generator

Ang co_yield ang ekspresyon ay nabubuo sa ibabaw ng co_await upang suportahan ang pag-uugaling parang generator, kung saan ang coroutine ay paulit-ulit na gumagawa ng mga halaga para sa isang tumatawag na umuulit sa mga ito.

Konseptwal, co_yield value; lumalawak sa isang panawagan sa promise.yield_value(value) at pagkatapos ay isang suspensyon, karaniwang sa pamamagitan ng co_await std::suspend_always o isang katulad na hinihintay. Ang implementasyon ng pangako ay responsable sa pag-iimbak ng ibinigay na halaga sa isang lugar na naa-access (sa pamamagitan ng pagkopya, paglipat o pagtukoy dito) upang makuha ito ng mamimili bago magpatuloy ang coroutine.

Ang library code na nagpapatupad ng mga generator ay karaniwang tumutukoy sa isang uri ng pangako na naglalantad ng mga pamamaraan para sa pag-access sa kasalukuyang yield value at para sa pagsasama sa mga karaniwang iteration protocol, tulad ng pagbibigay begin()/end() sa balot ng hawakan at pagsulong sa pinagbabatayang coroutine sa bawat pagtaas.

Paghawak ng error, mga nakabitin na sanggunian at mga banayad na detalye

Ang mga C++20 coroutine ay nagsasama sa C++ exception handling sa pamamagitan ng promise's unhandled_exception() pamamaraan, na tinatawag ng compiler kung ang isang exception ay makatakas sa coroutine body. Pagkatapos, ang coroutine ay magpapatuloy sa huling suspensyon nito, at ang promise ay inaasahang magsasaayos para maipabatid ang error sa sinumang nagmamay-ari ng uri ng resulta ng coroutine.

Dahil ang mga parameter ay kinokopya o isinasangguni sa coroutine frame sa oras ng paglikha, dapat mag-ingat sa mga reference parameter: kung ang mga ito ay tumutukoy sa mga object na ang lifetime ay nagtatapos bago ipagpatuloy ang coroutine, maaaring alisin ng coroutine ang reference sa mga nakalawit na reference. Hindi ito isang problemang partikular sa coroutine, ngunit ang persistent na katangian ng frame ay ginagawang mas madali ang aksidenteng mabuhay nang mas matagal kaysa sa mga referenced object.

Ang pamantayan ay umunlad din upang linawin ang mga edge case sa pamamagitan ng mga ulat ng depekto, tulad ng paggawa ng tiyak na hindi wasto return_void mga setup na hindi maayos ang pagkakabuo sa halip na makagawa ng hindi natukoy na pag-uugali kapag nahuhulog mula sa dulo ng isang coroutine, at pinapayagan co_await sa mas maraming konteksto tulad ng mga lambda bodies.

Sama-sama, ang mga patakaran at pagpipino na ito ay humuhubog sa isang medyo mababang antas ngunit nahuhulaang modelo kung saan ligtas na makakabuo ng mga coroutine library na may mas mataas na antas, mula sa mga simpleng generator hanggang sa mga full async/await task system na isinama sa mga executor at scheduler.

Kung titingnan mula sa itaas, ang paglalakbay mula sa mga ad-hoc assembly trick sa C patungo sa nakabalangkas, stackless, at promise-driven na mga coroutine ng C++20 ay sumasalamin sa isang matatag na pagsulong patungo sa mas ligtas at mas composable na mga abstraction para sa pagpapahayag ng kumplikadong daloy ng kontrol, mga asynchronous na operasyon, at mga stateful na kalkulasyon, nang hindi isinusuko ang pagganap at kontrol na inaasahan ng mga system programmer.

Kaugnay na mga post: