事件迴圈(event loop)
想像一間繁忙診所只有一位櫃台人員、沒有其他職員。她不會一對一陪著每位病人,而是守在櫃台等著「有事發生」——新病人到來、電話響起、檢驗結果回來——迅速處理那一件事,然後立刻等下一件。這個「等待—處理—再等待」的循環就是事件迴圈,也是一個執行緒得以讓數千條網路連線持續運轉、而不必一條連線一個執行緒的方式。
具體而言,事件迴圈是一個包在就緒呼叫外面的緊湊循環。第一步:阻塞在 epoll_wait()(或 poll、或 kqueue)裡,直到一個或多個描述符就緒。第二步:對每個就緒描述符,跑一個短而非阻塞的處理常式——接受一條新連線、讀取可用的位元組、寫出核心願意收的那麼多——然後返回迴圈。第三步:回頭再次等待。鐵則是任何處理常式都不可阻塞:每個處理常式只在非阻塞描述符上做一小段有界的工作、隨即交還控制權,因為當這單一迴圈執行緒卡住時,它服務的「每一條」連線都被凍結。計時器與排隊的回呼,則藉由用最近的待處理計時器算出等待的逾時值而融入其中。
為何重要:事件迴圈是 nginx、Redis、Node.js、libuv 與多數非同步框架的執行時核心。它把「許多並行連線」轉換成「從單一等待點派發的許多小回呼」,以放棄阻塞式程式碼的簡單,換取巨大的可擴展性。它最大的弱點與其鐵則同源:一個緩慢或阻塞的處理常式——一次同步檔案讀取、一個沉重的 CPU 迴圈、一次阻塞的 DNS 呼叫——會卡住整個迴圈、連同它的每一條連線,這正是這類工作被推給執行緒池或被改成非同步的原因。
for (;;) { int n = epoll_wait(ep, evs, MAX, next_timer_ms()); for (int i = 0; i < n; i++) handle_ready(evs[i].data.ptr); run_due_timers(); }
等待就緒、為每個就緒事件派發一個短而非阻塞的處理常式、觸發到期的計時器,然後重複。
事件迴圈在一個執行緒上給的是並行、而非平行:處理常式一次跑一個,所以 CPU 沉重的工作必須移出迴圈,否則它會阻塞每一條連線。這與決定要恢復哪個被掛起任務的「任務排程器/非同步執行器」是不同的層次——迴圈只負責等待與派發。