קורוטינות מעל מערכת ההפעלה¶
נניח שאנחנו רוצים לכתוב "קרא כותרת, בדוק אותה ואז קרא תוכן" בלי לפצל את ההיגיון לשלושה callbacks. קורוטינה מאפשרת להשעות פונקציה ולהמשיך אותה בהמשך. היא אינה thread חדש, ואינה הופכת קריאה חוסמת לאסינכרונית מעצמה. מישהו עדיין צריך להגיש את ה־I/O ולקשר את ההשלמה להמשך הפונקציה.
המצב שנדרש בין נקודות ההשהיה נשמר ב־coroutine frame. הוא יכול לכלול פרמטרים, משתנים מקומיים, promise ומצב של awaiter. המסגרת היא משאב עם בעלות, בדיוק כמו buffer. אם ה־OVERLAPPED נמצא בה והמסגרת נהרסת מוקדם, המערכת נשארת עם כתובת לא תקפה.
התחלה קטנה שאפשר לראות¶
התוכנית הבאה אינה מבצעת I/O. היא מראה מסגרת שנעצרת פעם אחת והמשך שמופעל במפורש:
#include <coroutine>
#include <exception>
#include <iostream>
#include <utility>
class Step {
public:
struct promise_type {
Step get_return_object() {
return Step{
std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_void() noexcept {}
void unhandled_exception() { std::terminate(); }
};
using Handle = std::coroutine_handle<promise_type>;
explicit Step(Handle handle) : handle_(handle) {}
Step(Step&& other) noexcept
: handle_(std::exchange(other.handle_, {})) {}
Step(const Step&) = delete;
~Step() { if (handle_) handle_.destroy(); }
bool resume() {
if (!handle_ || handle_.done()) return false;
handle_.resume();
return !handle_.done();
}
private:
Handle handle_;
};
Step example() {
int value = 7;
std::cout << "first=" << value << '\n';
co_await std::suspend_always{};
++value;
std::cout << "second=" << value << '\n';
}
int main() {
auto task = example();
while (task.resume()) {}
}
ה־initial_suspend דוחה את תחילת גוף הפונקציה עד resume ראשון. המשתנה value שורד את ההשהיה משום שהמהדר משמר את המצב הנדרש במסגרת. ה־final_suspend משאיר את המסגרת קיימת אחרי סיום הגוף, כדי שהבעלים יוכל להרוס אותה פעם אחת. אין לקרוא resume על קורוטינה שכבר הגיעה לסיום.
ה־Step הפשוט מותר להריסה בזמן השהיה מפני שאין גורם חיצוני שמחזיק כתובת מתוכה. העתקת אותו destructor למתאם I/O תהיה טעות. ברגע שהקרנל מחזיק OVERLAPPED מתוך המסגרת, הבעלים חייב להמתין להשלמה לפני destroy.
חוזה של awaiter¶
ה־awaiter קובע שלושה שלבים: בדיקה אם התוצאה זמינה, רישום המשך בזמן ההשהיה ואיסוף התוצאה כשממשיכים. במתאם IOCP של הפרויקט נשתמש במצב שבו ההגשה וריקון התור מתבצעים על אותו thread. ההגבלה מכוונת: שום callback אחר אינו יכול לחדש את הקורוטינה באמצע await_suspend.
הקטע הבא הוא ליבת המתאם לשילוב ב־event loop, ולא תוכנית עצמאית. המבנה Request מכיל OVERLAPPED יציב, buffer, שגיאה, ספירת בתים ו־coroutine_handle בשם continuation. המנוע מספק רישום והסרה של בקשה לפני הגשה ואחרי השלמה:
bool await_ready() const noexcept { return false; }
bool await_suspend(std::coroutine_handle<> continuation) noexcept {
request.continuation = continuation;
if (!loop.register_request(request)) {
request.error = ERROR_BUSY;
return false;
}
DWORD ignored = 0;
const BOOL ok = ReadFile(file, request.buffer.data(),
static_cast<DWORD>(request.buffer.size()),
&ignored, &request.operation);
if (!ok) {
const DWORD error = GetLastError();
if (error != ERROR_IO_PENDING) {
loop.unregister_request(request);
request.error = error;
return false;
}
}
return true;
}
DWORD await_resume() {
if (request.error != ERROR_SUCCESS)
throw std::system_error(static_cast<int>(request.error),
std::system_category());
return request.bytes;
}
ה־false שחוזר מ־await_suspend אומר שאין להישאר בהשהיה, ולכן await_resume יכול לדווח על שגיאת הגשה. במסלול שהתקבל, כולל הצלחה מיידית עם הודעות completion רגילות, המנוע יחדש את הקורוטינה אחרי צריכת החבילה. ההגדרה המלאה של Request ושל המנוע מופיעה בפתרון פרויקט הפרק, עם קוד שאפשר לבנות כמכלול.
החידוש יכול להרוס את מה שחידשנו¶
ה־dispatcher מוצא את הבקשה, מסיר אותה מטבלת הפעילות ושומר בה את התוצאה. לאחר מכן הוא מעתיק את continuation למשתנה מקומי וקורא resume. מרגע הקריאה, הקורוטינה יכולה לעבור הלאה ולסיים את חיי ה־awaiter. לכן אין לגשת שוב לבקשה דרך אותו pointer אחרי resume אלא אם יש הוכחת חיים נפרדת.
במנוע עם כמה threads, completion יכול להגיע בזמן שהגשת הבקשה עדיין חוזרת. אחרי פרסום handle ההמשך, thread אחר עשוי לחדש את הקורוטינה ואף להרוס את ה־awaiter לפני ש־await_suspend סיימה. פתרון דורש handshake מפורש בין "נרשם", "הושעה" ו"הושלם", או תזמון שמבטיח שחידוש מתבצע רק לאחר יציאה מן ההגשה. הוספת atomic bool בלי מכונת מצבים אינה מספיקה.
ביטול וחריגות¶
בקשת ביטול צריכה להגיע ל־CancelIoEx של הבקשה הפעילה, אבל המסגרת נשארת חיה עד שהשלמתה נצרכה. אחרי completion אפשר לחדש את הקורוטינה עם שגיאה ולתת לה לנקות את משתניה. בעל task שרוצה להפסיק אינו רשאי פשוט לקרוא destroy.
ה־promise יכול לשמור exception_ptr כדי שהבעלים יקבל שגיאה אחרי הסיום. בכך נשמר ההבדל בין כשל הקצאת המסגרת, כשל הגשה וכשל שמתקבל בהשלמה. גם פרמטר reference לקורוטינה דורש זהירות: המסגרת שומרת reference, ולא בהכרח עותק של האובייקט שאליו הוא מצביע.
ממשק future מפריד גם הוא בין תוצאה עתידית לבין הקוד שמייצר אותה, אבל אין להניח שכל future הוא awaitable או שכל קורוטינה משתמשת ב־future. סוג ההחזרה, ה־promise וה־awaiter הם אלה שמגדירים את המדיניות.
קריאה להעמקה¶
ההגדרה של פונקציות קורוטינה ושל ביטוי ההמתנה מסבירה את סדר הקריאות. חברו אותה לחוזה IOCP של השיעור הקודם לפני שמרחיבים את מספר עובדי ה־dispatcher.