לדלג לתוכן

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 מסורתי

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

// every call = syscall = context switch
ssize_t n = read(fd, buf, size);
ssize_t n = write(fd, buf, size);

הגישה עם epoll

// syscall 1: wait for events
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
    // syscall 2: read/write
    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 - כתובת הbuffer
- len - גודל
- offset - היסט בקובץ
- user_data - ערך שרירותי שיחזור ב-CQE (כדי לזהות את הבקשה)
- flags - דגלים (למשל IOSQE_IO_LINK לשרשור פעולות)

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

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

הזרימה

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

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

ספריית liburing

למה liburing

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

התקנה:

# Ubuntu/Debian
sudo apt install liburing-dev

# or build from source
git clone https://github.com/axboe/liburing
cd liburing && ./configure && make && sudo make install

הAPI הבסיסי

#include <liburing.h>

struct io_uring ring;

// initialization - 256 = the queue size
io_uring_queue_init(256, &ring, 0);

// get a free SQE
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);

// prepare a read operation
io_uring_prep_read(sqe, fd, buf, buf_size, offset);

// tag with user_data for identification
io_uring_sqe_set_data(sqe, my_context);

// submit all the accumulated requests
io_uring_submit(&ring);

// wait for a result
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);

// process the result
int result = cqe->res;                          // return value
void *context = io_uring_cqe_get_data(cqe);     // our user_data

// mark that we're done with this result
io_uring_cqe_seen(&ring, cqe);

// cleanup
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

// event types - we'll store in 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);

    // start accepting connections
    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);

                // start reading from the client
                add_read(client_fd);

                // continue accepting new connections
                add_accept(server_fd);
            }
        } else if (conn->type == EVENT_READ) {
            if (res <= 0) {
                // the client closed the connection
                printf("connection closed: fd=%d\n", conn->fd);
                close(conn->fd);
            } else {
                // send back (echo)
                add_write(conn->fd, conn->buf, res);

                // continue reading
                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

בדיקה:

# in one terminal
./echo_uring

# in another terminal
nc localhost 8080
hello        # <- you type
hello        # <- comes back

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

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...)
מורכבות פשוט יחסית יותר מורכב

איור מונפש שמשווה את מספר קריאות המערכת לכל פעולת קלט פלט בקריאה חוסמת, בגישת epoll, בio_uring, ובמצב SQPOLL

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

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

מתי epoll מספיק

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

מצב SQPOLL

אפס syscalls

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

struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000;  // the kernel thread sleeps after 2 seconds of inactivity

io_uring_queue_init_params(QUEUE_DEPTH, &ring, &params);

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

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

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

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

// read from the file, then write to the 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;  // chain to the next operation

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

io_uring_submit(&ring);
// the read from the file will happen first, and only after it the write to the socket

הערת אבטחה

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

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

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