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:
- תור הגשה - Submission Queue (SQ): המשתמש כותב בקשות I/O לכאן.
- תור השלמה - 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 (לזיהוי)
הזרימה¶
- קבל SQE פנוי מתור ההגשה.
- מלא את פרטי הפעולה (opcode, fd, buffer, ...).
- שלח (submit) - אפשר עם
io_uring_enter()syscall, או בלי syscall כלל במצב SQPOLL. - חכה להשלמה (או בדוק אם יש) - קרא מתור ההשלמה.
- עבד את התוצאה.
ספריית 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;
}
קומפילציה:
בדיקה:
השוואת ביצועים¶
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, ¶ms);
ב-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 בלינוקס. אבל צריך להיות מודעים.