לדלג לתוכן

12.3 io uring הרצאה

הקדמה

בפרק 11.2 למדנו על epoll לריבוב קלט/פלט. ראינו שepoll מאפשר לנו לנטר הרבה file descriptors ולדעת מתי אחד מהם מוכן לקריאה או כתיבה. אבל יש בעיה: epoll רק אומר לנו "הfd מוכן", הפעולה עצמה (read, write) היא עדיין syscall חוסם.

כל syscall זה context switch - מuser space לkernel space ובחזרה. כשעושים אלפי פעולות I/O בשנייה, התקורה מצטברת.

הפתרון: io_uring - ממשק I/O אסינכרוני מודרני בלינוקס (מגרסה 5.1, 2019). במקום לעשות syscall לכל פעולה, אנחנו שולחים בקשות דרך ring buffer משותף עם הקרנל, והקרנל מחזיר תוצאות דרך ring buffer אחר. במקרה הטוב - אפס syscalls בנתיב החם.


הבעיה עם I/O מסורתי

הגישה הקלאסית

// כל קריאה = syscall = context switch
ssize_t n = read(fd, buf, size);
ssize_t n = write(fd, buf, size);

הגישה עם epoll

// syscall 1: חכה לאירועים
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
    // syscall 2: קרא/כתוב
    read(events[i].data.fd, buf, size);
}

עדיין שני syscalls לכל פעולה (epoll_wait + read/write). ולכל syscall יש תקורה: שמירת רגיסטרים, מעבר לkernel mode, חזרה.

AIO הישן

לינוקס הציעה בעבר POSIX AIO (aio_read, aio_write) אבל המימוש היה גרוע: הוא השתמש בthreads בuser space, לא היה באמת אסינכרוני ברמת הקרנל, ולא תמך בכל סוגי הפעולות. אף אחד לא אהב את זה.


איך io_uring עובד

הארכיטקטורה

הרעיון המרכזי: שני ring buffers שממופים (mmap) בין user space ל-kernel space:

  1. תור הגשה - Submission Queue (SQ): המשתמש כותב בקשות I/O לכאן.
  2. תור השלמה - Completion Queue (CQ): הקרנל כותב תוצאות לכאן.
user space                          kernel space
+-------------------+              +-------------------+
|                   |              |                   |
|  SQ (submissions) | ---mmap----> |  SQ (submissions) |
|  [req1][req2][..] |              |  [req1][req2][..] |
|                   |              |                   |
|  CQ (completions) | <---mmap--- |  CQ (completions) |
|  [res1][res2][..] |              |  [res1][res2][..] |
|                   |              |                   |
+-------------------+              +-------------------+

כי זה זכרון משותף (mmap), אין צורך בsyscall כדי להעביר נתונים! המשתמש כותב ל-SQ, הקרנל קורא משם. הקרנל כותב ל-CQ, המשתמש קורא משם.

רשומת הגשה - Submission Queue Entry (SQE)

כל בקשה מתוארת ב-SQE שמכיל:
- opcode - סוג הפעולה (IORING_OP_READ, IORING_OP_WRITE, IORING_OP_ACCEPT, ...)
- fd - הfile descriptor
- addr - כתובת החוצץ
- len - גודל
- offset - היסט בקובץ
- user_data - ערך שרירותי שיחזור ב-CQE (כדי לזהות את הבקשה)
- flags - דגלים (למשל IOSQE_IO_LINK לשרשור פעולות)

רשומת השלמה - Completion Queue Entry (CQE)

כל תוצאה מכילה:
- res - ערך החזרה (כמו מsyscall: מספר בתים שנקראו, או שגיאה שלילית)
- user_data - אותו ערך ששלחנו ב-SQE (לזיהוי)

הזרימה

  1. קבל SQE פנוי מתור ההגשה.
  2. מלא את פרטי הפעולה (opcode, fd, buffer, ...).
  3. שלח (submit) - אפשר עם io_uring_enter() syscall, או בלי syscall כלל במצב SQPOLL.
  4. חכה להשלמה (או בדוק אם יש) - קרא מתור ההשלמה.
  5. עבד את התוצאה.

ספריית liburing

למה liburing

הAPI הגולמי של io_uring (מבוסס mmap ו-io_uring_setup/io_uring_enter) הוא מסובך. ספריית liburing של Jens Axboe (מפתח io_uring) מספקת API נוח ונקי.

התקנה:

# אובונטו/דביאן
sudo apt install liburing-dev

# או בנייה מהמקור
git clone https://github.com/axboe/liburing
cd liburing && ./configure && make && sudo make install

הAPI הבסיסי

#include <liburing.h>

struct io_uring ring;

// אתחול - 256 = גודל התור
io_uring_queue_init(256, &ring, 0);

// קבל SQE פנוי
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);

// הכן פעולת קריאה
io_uring_prep_read(sqe, fd, buf, buf_size, offset);

// סמן עם user_data לזיהוי
io_uring_sqe_set_data(sqe, my_context);

// שלח את כל הבקשות שהצטברו
io_uring_submit(&ring);

// חכה לתוצאה
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);

// עבד את התוצאה
int result = cqe->res;                          // ערך חזרה
void *context = io_uring_cqe_get_data(cqe);     // הuser_data שלנו

// סמן שסיימנו עם התוצאה הזו
io_uring_cqe_seen(&ring, cqe);

// סיום
io_uring_queue_exit(&ring);

פעולות נתמכות

io_uring תומך בהרבה מאוד פעולות:

פעולה תיאור
io_uring_prep_read קריאה מfd
io_uring_prep_write כתיבה לfd
io_uring_prep_accept קבלת חיבור (socket)
io_uring_prep_connect התחברות לשרת
io_uring_prep_send שליחת נתונים (socket)
io_uring_prep_recv קבלת נתונים (socket)
io_uring_prep_openat פתיחת קובץ
io_uring_prep_close סגירת fd
io_uring_prep_fsync סנכרון קובץ לדיסק

דוגמה: שרת echo עם io_uring

הנה שרת echo שמשתמש ב-io_uring. השוו אותו לגרסת epoll מפרק 11:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <liburing.h>

#define PORT 8080
#define MAX_CONNS 1024
#define BUF_SIZE 1024
#define QUEUE_DEPTH 256

// סוגי אירועים - נשמור בuser_data
enum event_type {
    EVENT_ACCEPT,
    EVENT_READ,
    EVENT_WRITE
};

typedef struct {
    int fd;
    enum event_type type;
    char buf[BUF_SIZE];
    int buf_len;
} conn_info_t;

struct io_uring ring;

int setup_listening_socket(void) {
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr = {
        .sin_family = AF_INET,
        .sin_port = htons(PORT),
        .sin_addr.s_addr = INADDR_ANY
    };

    bind(fd, (struct sockaddr *)&addr, sizeof(addr));
    listen(fd, 128);
    return fd;
}

void add_accept(int server_fd) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);

    conn_info_t *conn = calloc(1, sizeof(conn_info_t));
    conn->fd = server_fd;
    conn->type = EVENT_ACCEPT;

    io_uring_prep_accept(sqe, server_fd, NULL, NULL, 0);
    io_uring_sqe_set_data(sqe, conn);
}

void add_read(int client_fd) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);

    conn_info_t *conn = calloc(1, sizeof(conn_info_t));
    conn->fd = client_fd;
    conn->type = EVENT_READ;

    io_uring_prep_recv(sqe, client_fd, conn->buf, BUF_SIZE, 0);
    io_uring_sqe_set_data(sqe, conn);
}

void add_write(int client_fd, char *buf, int len) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);

    conn_info_t *conn = calloc(1, sizeof(conn_info_t));
    conn->fd = client_fd;
    conn->type = EVENT_WRITE;
    memcpy(conn->buf, buf, len);
    conn->buf_len = len;

    io_uring_prep_send(sqe, client_fd, conn->buf, len, 0);
    io_uring_sqe_set_data(sqe, conn);
}

int main(void) {
    int server_fd = setup_listening_socket();
    printf("echo server listening on port %d\n", PORT);

    io_uring_queue_init(QUEUE_DEPTH, &ring, 0);

    // התחל לקבל חיבורים
    add_accept(server_fd);
    io_uring_submit(&ring);

    while (1) {
        struct io_uring_cqe *cqe;
        io_uring_wait_cqe(&ring, &cqe);

        conn_info_t *conn = io_uring_cqe_get_data(cqe);
        int res = cqe->res;

        if (conn->type == EVENT_ACCEPT) {
            if (res >= 0) {
                int client_fd = res;
                printf("new connection: fd=%d\n", client_fd);

                // התחל לקרוא מהלקוח
                add_read(client_fd);

                // המשך לקבל חיבורים חדשים
                add_accept(server_fd);
            }
        } else if (conn->type == EVENT_READ) {
            if (res <= 0) {
                // הלקוח סגר את החיבור
                printf("connection closed: fd=%d\n", conn->fd);
                close(conn->fd);
            } else {
                // שלח חזרה (echo)
                add_write(conn->fd, conn->buf, res);

                // המשך לקרוא
                add_read(conn->fd);
            }
        } else if (conn->type == EVENT_WRITE) {
            if (res < 0) {
                printf("write error: fd=%d\n", conn->fd);
                close(conn->fd);
            }
        }

        io_uring_cqe_seen(&ring, cqe);
        free(conn);

        io_uring_submit(&ring);
    }

    io_uring_queue_exit(&ring);
    close(server_fd);
    return 0;
}

קומפילציה:

gcc -o echo_uring echo_uring.c -luring

בדיקה:

# בטרמינל אחד
./echo_uring

# בטרמינל אחר
nc localhost 8080
hello        # <- מקלידים
hello        # <- חוזר


השוואת ביצועים

epoll מול io_uring

קריטריון epoll io_uring
syscalls לפעולה 2 (epoll_wait + read/write) 0-1 (submit בkernl polling)
אצוות (batching) לא כן - שולחים הרבה בקשות בבת אחת
סקר בצד הקרנל (kernel polling) לא כן - SQPOLL mode
תמיכה בפעולות רק notify (מוכנות) פעולות שלמות (read, write, accept...)
מורכבות פשוט יחסית יותר מורכב

מתי io_uring שווה את זה

  • שרתים שמטפלים בעשרות אלפי חיבורים.
  • אפליקציות I/O אינטנסיביות (מסדי נתונים, שרתי קבצים).
  • כשתקורת syscalls היא צוואר בקבוק מדיד.

מתי epoll מספיק

  • ברוב השרתים הרגילים.
  • כשהלוגיקה (עיבוד הבקשה) לוקחת יותר זמן מה-I/O עצמו.
  • כשרוצים פשטות.

מצב SQPOLL

אפס syscalls

במצב SQPOLL, הקרנל מריץ תהליכון קרנל שסורק את ה-SQ באופן קבוע (polling). ברגע שהמשתמש כותב בקשה ל-SQ, תהליכון הקרנל מזהה אותה ומבצע - בלי שום syscall מצד המשתמש.

struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000;  // תהליכון הkernel ישן אחרי 2 שניות ללא פעילות

io_uring_queue_init_params(QUEUE_DEPTH, &ring, &params);

ב-SQPOLL mode, גם io_uring_submit לא צריך syscall - הוא פשוט כותב ל-ring buffer וזהו.

שימו לב: SQPOLL צורך ליבת CPU בקרנל. משתלם רק כשיש עומס גבוה וקבוע.


שרשור פעולות - operation chaining

אפשר לשרשר פעולות כך שהשנייה תתחיל רק אחרי שהראשונה מסתיימת:

// קרא מקובץ, ואז כתוב לsocket
struct io_uring_sqe *sqe1 = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe1, file_fd, buf, size, 0);
sqe1->flags |= IOSQE_IO_LINK;  // שרשר לפעולה הבאה

struct io_uring_sqe *sqe2 = io_uring_get_sqe(&ring);
io_uring_prep_send(sqe2, socket_fd, buf, size, 0);

io_uring_submit(&ring);
// הקריאה מהקובץ תתבצע קודם, ורק אחריה הכתיבה לsocket

הערת אבטחה

חשוב לדעת: ל-io_uring היו כמה פרצות אבטחה רציניות מאז שהוא יצא. בגלל הגישה הישירה לringbuffers משותפים עם הקרנל, באגים בקוד הקרנל של io_uring יכולים לאפשר הסלמת הרשאות (privilege escalation).

בגלל זה:
- Docker חוסם io_uring כברירת מחדל (seccomp).
- חלק מספקי הענן חוסמים io_uring בVMs.
- גוגל חסמה io_uring ב-ChromeOS וב-Android.

זה לא אומר שלא צריך ללמוד את זה - זה הכיוון של I/O בלינוקס. אבל צריך להיות מודעים.