לדלג לתוכן

12.4 - תכנות אסינכרוני עם eventloop - הרצאה

הקדמה

איך Node.js מטפל באלפי חיבורים עם thread אחד? איך Redis, אחד ממסדי הנתונים המהירים בעולם, עובד בthread יחיד? איך Nginx מגיש אלפי בקשות בו-זמנית?

התשובה: לולאת אירועים - event loop.

בפרק 11 למדנו על epoll שנותן לנו לנטר הרבה file descriptors. בפרק 12.3 ראינו את io_uring שמבצע I/O אסינכרוני. עכשיו נלמד את הדפוס שמחבר את הכל: ה-event loop.

הרעיון

לולאת אירועים היא הפשטות עצמה:

while (true) {
    events = wait_for_events();    // epoll_wait / io_uring_wait
    for each event:
        handle(event);             // call the appropriate callback
}

זה הכל. אין threads מרובים, אין mutex, אין deadlock. הthread אחד שמחכה לאירועים ומטפל בהם אחד אחרי השני.

תרשים זרימה מונפש של סיבוב בלולאת אירועים, שבו ממתינים לאירוע, מוצאים את הפונקציה המתאימה לפי המתאר, מריצים אותה, וחוזרים להמתנה

הכלל הזהב

שום דבר לא חוסם. כל handler חייב לסיים מהר. אסור:
- sleep() - תקפיא את כל השרת.
- קריאת I/O חוסמת (blocking read) - כל הלקוחות יחכו.
- חישוב כבד - כל הלקוחות יחכו.

אם צריך לעשות משהו שלוקח זמן - תעביר את זה לthread pool נפרד (כמו שלמדנו ב-12.1) ותקבל את התוצאה כאירוע.

מימוש event loop בסיסי

בואו נבנה event loop פשוט אבל שלם מעל epoll:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <sys/timerfd.h>
#include <sys/signalfd.h>
#include <signal.h>
#include <errno.h>
#include <stdbool.h>

#define MAX_EVENTS 64
#define MAX_FDS 1024

// callback types
typedef void (*event_callback_t)(int fd, void *data);

// event registration
typedef struct {
    int fd;
    event_callback_t on_read;
    event_callback_t on_write;
    void *data;
    bool active;
} event_source_t;

// the event loop
typedef struct {
    int epoll_fd;
    event_source_t sources[MAX_FDS];
    bool running;
} event_loop_t;

event_loop_t *event_loop_create(void) {
    event_loop_t *loop = calloc(1, sizeof(event_loop_t));
    loop->epoll_fd = epoll_create1(0);
    loop->running = true;
    return loop;
}

void event_loop_destroy(event_loop_t *loop) {
    close(loop->epoll_fd);
    free(loop);
}

// register fd for reading
void event_loop_add_reader(event_loop_t *loop, int fd,
                            event_callback_t callback, void *data) {
    struct epoll_event ev = {
        .events = EPOLLIN,
        .data.fd = fd
    };
    epoll_ctl(loop->epoll_fd, EPOLL_CTL_ADD, fd, &ev);

    loop->sources[fd].fd = fd;
    loop->sources[fd].on_read = callback;
    loop->sources[fd].data = data;
    loop->sources[fd].active = true;
}

// register fd for writing
void event_loop_add_writer(event_loop_t *loop, int fd,
                            event_callback_t callback, void *data) {
    struct epoll_event ev = {
        .events = EPOLLOUT,
        .data.fd = fd
    };
    epoll_ctl(loop->epoll_fd, EPOLL_CTL_ADD, fd, &ev);

    loop->sources[fd].fd = fd;
    loop->sources[fd].on_write = callback;
    loop->sources[fd].data = data;
    loop->sources[fd].active = true;
}

// remove fd
void event_loop_remove(event_loop_t *loop, int fd) {
    epoll_ctl(loop->epoll_fd, EPOLL_CTL_DEL, fd, NULL);
    loop->sources[fd].active = false;
}

// stop the loop
void event_loop_stop(event_loop_t *loop) {
    loop->running = false;
}

// the loop itself
void event_loop_run(event_loop_t *loop) {
    struct epoll_event events[MAX_EVENTS];

    while (loop->running) {
        int n = epoll_wait(loop->epoll_fd, events, MAX_EVENTS, -1);
        if (n < 0) {
            if (errno == EINTR) continue;
            perror("epoll_wait");
            break;
        }

        for (int i = 0; i < n; i++) {
            int fd = events[i].data.fd;
            event_source_t *src = &loop->sources[fd];

            if (!src->active) continue;

            if ((events[i].events & EPOLLIN) && src->on_read) {
                src->on_read(fd, src->data);
            }
            if ((events[i].events & EPOLLOUT) && src->on_write) {
                src->on_write(fd, src->data);
            }
        }
    }
}

שימוש

// read handler
void on_stdin_read(int fd, void *data) {
    (void)data;
    char buf[256];
    ssize_t n = read(fd, buf, sizeof(buf) - 1);
    if (n > 0) {
        buf[n] = '\0';
        printf("got: %s", buf);
    }
}

int main(void) {
    event_loop_t *loop = event_loop_create();

    // listen on stdin
    event_loop_add_reader(loop, STDIN_FILENO, on_stdin_read, NULL);

    printf("type something (Ctrl+C to quit):\n");
    event_loop_run(loop);

    event_loop_destroy(loop);
    return 0;
}

אירועי טיימר - timerfd

לינוקס מספקת timerfd - fd שמתנהג כמו טיימר. כשהטיימר פוקע, הfd הופך ל"מוכן לקריאה", ואפשר לשלב אותו ב-event loop:

#include <sys/timerfd.h>

// create a timer that fires every second
int create_timer(int interval_sec) {
    int tfd = timerfd_create(CLOCK_MONOTONIC, 0);

    struct itimerspec spec = {
        .it_interval = { .tv_sec = interval_sec, .tv_nsec = 0 },  // recurring
        .it_value    = { .tv_sec = interval_sec, .tv_nsec = 0 }   // first
    };
    timerfd_settime(tfd, 0, &spec, NULL);

    return tfd;
}

// timer handler
void on_timer(int fd, void *data) {
    uint64_t expirations;
    read(fd, &expirations, sizeof(expirations));  // must read
    printf("timer fired! (%lu expirations)\n", expirations);
}

// usage with the event loop
int tfd = create_timer(1);  // every second
event_loop_add_reader(loop, tfd, on_timer, NULL);

אירועי סיגנלים - signalfd

בדומה ל-timerfd, אפשר להפוך סיגנלים לfd-ים עם signalfd:

#include <sys/signalfd.h>
#include <signal.h>

int create_signal_fd(void) {
    sigset_t mask;
    sigemptyset(&mask);
    sigaddset(&mask, SIGINT);
    sigaddset(&mask, SIGTERM);

    // block the signals so signalfd receives them
    sigprocmask(SIG_BLOCK, &mask, NULL);

    return signalfd(-1, &mask, 0);
}

void on_signal(int fd, void *data) {
    event_loop_t *loop = (event_loop_t *)data;
    struct signalfd_siginfo info;
    read(fd, &info, sizeof(info));

    printf("got signal %d\n", info.ssi_signo);
    event_loop_stop(loop);
}

// usage
int sfd = create_signal_fd();
event_loop_add_reader(loop, sfd, on_signal, loop);

עכשיו Ctrl+C לא הורג את התוכנית ישר, אלא עובר דרך event loop ומאפשר כיבוי מסודר (graceful shutdown).

דוגמה שלמה: event loop עם טיימר, סיגנלים, ו-stdin

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <sys/timerfd.h>
#include <sys/signalfd.h>
#include <signal.h>
#include <stdbool.h>
#include <errno.h>
#include <stdint.h>

// (event_loop_t definition and all the functions from above)

int tick_count = 0;

void on_timer(int fd, void *data) {
    uint64_t exp;
    read(fd, &exp, sizeof(exp));
    tick_count++;
    printf("[tick %d]\n", tick_count);
}

void on_stdin(int fd, void *data) {
    char buf[256];
    ssize_t n = read(fd, buf, sizeof(buf) - 1);
    if (n > 0) {
        buf[n] = '\0';
        // remove the newline
        if (buf[n-1] == '\n') buf[n-1] = '\0';
        printf("you typed: '%s'\n", buf);

        if (strcmp(buf, "quit") == 0) {
            event_loop_t *loop = (event_loop_t *)data;
            event_loop_stop(loop);
        }
    }
}

void on_signal(int fd, void *data) {
    struct signalfd_siginfo info;
    read(fd, &info, sizeof(info));
    printf("\ncaught signal %d, shutting down...\n", info.ssi_signo);
    event_loop_t *loop = (event_loop_t *)data;
    event_loop_stop(loop);
}

int main(void) {
    event_loop_t *loop = event_loop_create();

    // timer - every second
    int tfd = timerfd_create(CLOCK_MONOTONIC, 0);
    struct itimerspec ts = {
        .it_interval = { .tv_sec = 1 },
        .it_value = { .tv_sec = 1 }
    };
    timerfd_settime(tfd, 0, &ts, NULL);
    event_loop_add_reader(loop, tfd, on_timer, NULL);

    // stdin
    event_loop_add_reader(loop, STDIN_FILENO, on_stdin, loop);

    // signals
    sigset_t mask;
    sigemptyset(&mask);
    sigaddset(&mask, SIGINT);
    sigprocmask(SIG_BLOCK, &mask, NULL);
    int sfd = signalfd(-1, &mask, 0);
    event_loop_add_reader(loop, sfd, on_signal, loop);

    printf("event loop running. type 'quit' or Ctrl+C to exit.\n");
    event_loop_run(loop);

    close(tfd);
    close(sfd);
    event_loop_destroy(loop);
    printf("bye.\n");
    return 0;
}

קולבקים מול מכונת מצבים - callbacks vs state machines

בעיית הcallbacks

כשיש פרוטוקול מורכב (למשל HTTP), הלוגיקה מתפצלת בין הרבה callbacks, וקשה לעקוב אחרי הזרימה. זה מה שמכנים "callback hell":

// hard to follow: the logic is scattered
void on_request_read(int fd, void *data) {
    // read the request...
    // when done, register a callback for processing
}
void on_request_processed(int fd, void *data) {
    // process...
    // when done, register a callback for writing
}
void on_response_written(int fd, void *data) {
    // written...
    // go back to reading a new request
}

מכונת מצבים - state machine

גישה חלופית: כל חיבור מחזיק מצב (state), וhandler אחד מטפל בכל המצבים:

typedef enum {
    STATE_READING_REQUEST,
    STATE_PROCESSING,
    STATE_WRITING_RESPONSE,
} conn_state_t;

typedef struct {
    int fd;
    conn_state_t state;
    char read_buf[4096];
    int read_pos;
    char write_buf[4096];
    int write_pos;
    int write_len;
} connection_t;

void handle_connection(int fd, void *data) {
    connection_t *conn = (connection_t *)data;

    switch (conn->state) {
    case STATE_READING_REQUEST: {
        ssize_t n = read(fd, conn->read_buf + conn->read_pos,
                         sizeof(conn->read_buf) - conn->read_pos);
        if (n <= 0) { /* close */ return; }
        conn->read_pos += n;

        // check if we received a complete request
        if (strstr(conn->read_buf, "\r\n\r\n")) {
            conn->state = STATE_PROCESSING;
            // process the request (directly, since it's fast)
            process_request(conn);
            conn->state = STATE_WRITING_RESPONSE;
        }
        break;
    }

    case STATE_WRITING_RESPONSE: {
        ssize_t n = write(fd, conn->write_buf + conn->write_pos,
                          conn->write_len - conn->write_pos);
        if (n <= 0) { /* error */ return; }
        conn->write_pos += n;

        if (conn->write_pos >= conn->write_len) {
            // finished writing, go back to reading
            conn->state = STATE_READING_REQUEST;
            conn->read_pos = 0;
            conn->write_pos = 0;
        }
        break;
    }

    default:
        break;
    }
}

תרשים זרימה מונפש של מכונת מצבים לחיבור בלולאת אירועים, שעוברת ממצב קריאה לעיבוד ואז לכתיבה וחוזרת לקרוא את הבקשה הבאה

מכונת מצבים יותר ברורה כשיש פרוטוקול עם שלבים מוגדרים.

קורוטינות ותכנות אסינכרוני - coroutines and async/await

הבעיה

גם callbacks וגם מכונות מצבים מסרבלים: הלוגיקה שבקוד סינכרוני (רגיל) היא שורה אחרי שורה, אבל בקוד אסינכרוני היא מפוצלת.

קוד סינכרוני (פשוט, אבל חוסם):

request = read_full_request(fd);
response = process(request);
write_full_response(fd, response);

קוד אסינכרוני (לא חוסם, אבל מסורבל):

// split across 3 different callbacks, hard to follow

הפתרון: קורוטינות - coroutines

קורוטינה היא פונקציה שיכולה "להשהות" את עצמה ולחזור מאוחר יותר מאותה נקודה. זה מאפשר לכתוב קוד שנראה סינכרוני אבל עובד אסינכרוני:

// pseudo-code with coroutines
async function handle_client(fd):
    request = await read(fd)       // "pause" until there's data
    response = process(request)
    await write(fd, response)      // "pause" until the write completes

בC אין תמיכה מובנית בcoroutines (בניגוד ל-Go, Python, Rust, C#). אבל אפשר לממש אותן עם:
- setjmp/longjmp - שמירה ושחזור של הקונטקסט.
- ucontext - API יותר נוח (הוצא משימוש אבל עדיין עובד בלינוקס).
- תוספות קומפיילר (GCC coroutines).
- ספריות כמו libco, minicoro.

העניין הוא שהקונספט הזה הוא הבסיס לasync/await בכל השפות המודרניות:
- גורוטינות ב-Go - הruntime של Go מנהל אלפי goroutines מעל מספר קטן של threads.
- async/await ב-Python (asyncio) - coroutines שרצות על event loop.
- async/await ב-Rust - coroutines שמתקמפלות למכונת מצבים.
- async/await ב-JavaScript (Node.js) - coroutines מעל libuv event loop.

שרת HTTP פשוט עם event loop

בואו נבנה שרת HTTP מינימלי שמשתמש ב-event loop שלנו:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <netinet/in.h>
#include <errno.h>
#include <stdbool.h>

#define PORT 8080
#define MAX_EVENTS 64
#define BUF_SIZE 4096

typedef struct {
    int fd;
    char read_buf[BUF_SIZE];
    int read_len;
    char write_buf[BUF_SIZE];
    int write_len;
    int write_pos;
    bool writing;
} http_conn_t;

int epfd;

// make fd non-blocking
void set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

// build a simple HTTP response
void build_response(http_conn_t *conn) {
    const char *body = "<html><body><h1>Hello from C event loop!</h1></body></html>";
    conn->write_len = snprintf(conn->write_buf, BUF_SIZE,
        "HTTP/1.1 200 OK\r\n"
        "Content-Type: text/html\r\n"
        "Content-Length: %zu\r\n"
        "Connection: close\r\n"
        "\r\n"
        "%s",
        strlen(body), body);
    conn->write_pos = 0;
    conn->writing = true;
}

// handle read
void handle_read(http_conn_t *conn) {
    ssize_t n = read(conn->fd, conn->read_buf + conn->read_len,
                     BUF_SIZE - conn->read_len - 1);

    if (n <= 0) {
        if (n == 0 || errno != EAGAIN) {
            close(conn->fd);
            free(conn);
        }
        return;
    }

    conn->read_len += n;
    conn->read_buf[conn->read_len] = '\0';

    // check if we've received all the headers
    if (strstr(conn->read_buf, "\r\n\r\n")) {
        // build the response
        build_response(conn);

        // switch to write mode
        struct epoll_event ev = {
            .events = EPOLLOUT,
            .data.ptr = conn
        };
        epoll_ctl(epfd, EPOLL_CTL_MOD, conn->fd, &ev);
    }
}

// handle write
void handle_write(http_conn_t *conn) {
    ssize_t n = write(conn->fd, conn->write_buf + conn->write_pos,
                      conn->write_len - conn->write_pos);

    if (n <= 0) {
        if (errno != EAGAIN) {
            close(conn->fd);
            free(conn);
        }
        return;
    }

    conn->write_pos += n;

    if (conn->write_pos >= conn->write_len) {
        // done - close the connection
        close(conn->fd);
        free(conn);
    }
}

int main(void) {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    set_nonblocking(server_fd);

    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port = htons(PORT),
        .sin_addr.s_addr = INADDR_ANY
    };
    bind(server_fd, (struct sockaddr *)&addr, sizeof(addr));
    listen(server_fd, 128);

    epfd = epoll_create1(0);
    struct epoll_event ev = {
        .events = EPOLLIN,
        .data.fd = server_fd
    };
    epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &ev);

    printf("HTTP server listening on port %d\n", PORT);

    struct epoll_event events[MAX_EVENTS];
    while (true) {
        int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
        if (n < 0) {
            if (errno == EINTR) continue;
            break;
        }

        for (int i = 0; i < n; i++) {
            if (events[i].data.fd == server_fd) {
                // new connection
                struct sockaddr_in client_addr;
                socklen_t len = sizeof(client_addr);
                int client_fd = accept(server_fd,
                    (struct sockaddr *)&client_addr, &len);

                if (client_fd < 0) continue;
                set_nonblocking(client_fd);

                http_conn_t *conn = calloc(1, sizeof(http_conn_t));
                conn->fd = client_fd;

                struct epoll_event cev = {
                    .events = EPOLLIN,
                    .data.ptr = conn
                };
                epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &cev);
            } else {
                http_conn_t *conn = events[i].data.ptr;
                if (events[i].events & EPOLLIN) {
                    handle_read(conn);
                } else if (events[i].events & EPOLLOUT) {
                    handle_write(conn);
                }
            }
        }
    }

    close(server_fd);
    close(epfd);
    return 0;
}

קומפילציה ובדיקה:

gcc -o httpserver httpserver.c
./httpserver

# in another terminal, or in a browser:
curl http://localhost:8080

שימו לב: שרת אחד, thread אחד, אפס mutex-ים, ומטפל בהרבה לקוחות במקביל. זו העוצמה של event loop.

סיכום

גישה הthreads מורכבות ביצועים שימושים
הthread לכל חיבור רבים נמוכה בינוניים שרתים פשוטים
בריכת תהליכונים (thread pool) N קבועים בינונית טובים שרתי וב, מסדי נתונים
לולאת אירועים (event loop) 1 גבוהה מעולים שרתים עתירי I/O
היברידי (event loop + thread pool) 1 + N גבוהה הכי טובים שרתים מודרניים (Nginx, Node.js)

הגישה המודרנית ברוב המערכות היא היברידית: event loop ראשי שמטפל ב-I/O, ו-thread pool לעבודות חישוב כבדות. ככה מקבלים את הטוב משני העולמות.