io_submit: بديل لل epoll لم تسمع به من قبل



في الآونة الأخيرة ، تم جذب انتباه المؤلف إلى مقال على LWN حول واجهة kernel جديدة للاستطلاع. يناقش آلية الاقتراع الجديدة في Linux AIO API (واجهة لمعالجة الملفات غير المتزامنة) ، والتي تمت إضافتها إلى إصدار kernel 4.18. الفكرة مثيرة للغاية: يقترح مؤلف التصحيح استخدام Linux AIO API للعمل مع الشبكة.

لكن انتظر لحظة! بعد كل شيء ، تم إنشاء Linux AIO للعمل مع I / O غير المتزامن من قرص لآخر! الملفات الموجودة على القرص ليست هي نفس اتصالات الشبكة. هل من الممكن استخدام Linux AIO API للشبكات؟

اتضح ، نعم ، هذا ممكن! تشرح هذه المقالة كيفية استخدام نقاط القوة في Linux AIO API لإنشاء خوادم شبكة أسرع وأفضل.

لكن لنبدأ بشرح ماهية Linux AIO.

مقدمة لنظام Linux AIO


يوفر Linux AIO الإدخال / الإخراج غير المتزامن من قرص إلى قرص لبرنامج المستخدم.

تاريخيا ، على لينكس ، تم حظر جميع عمليات القرص. إذا اتصلت بـ open() أو read() أو write() أو fsync() ، فسيتوقف الدفق حتى تظهر البيانات fsync() في ذاكرة التخزين المؤقت للقرص. هذا عادة لا يمثل مشكلة. إذا لم يكن لديك العديد من عمليات الإدخال / الإخراج والذاكرة الكافية ، فستقوم مكالمات النظام بالتدريج في ملء ذاكرة التخزين المؤقت ، وسيعمل كل شيء بسرعة كافية.

يتناقص أداء عمليات الإدخال / الإخراج عندما يكون عددهم كبيرًا بدرجة كافية ، على سبيل المثال ، في حالات قواعد البيانات والوكلاء. بالنسبة إلى هذه التطبيقات ، من غير المقبول إيقاف العملية بالكامل من أجل انتظار مكالمة نظام read() واحدة.

لحل هذه المشكلة ، يمكن للتطبيقات استخدام ثلاث طرق:

  1. استخدم تجمعات مؤشرات الترابط ووظائف حظر المكالمات في مؤشرات ترابط منفصلة. هذه هي الطريقة التي يعمل بها POSIX AIO في glibc (لا تخلط بينه وبين Linux AIO). لمزيد من المعلومات ، راجع وثائق IBM . هذه هي الطريقة التي حللنا بها المشكلة في Cloudflare: نستخدم تجمع مؤشرات الترابط للاتصال read() و open() .
  2. قم posix_fadvise(2) ذاكرة التخزين المؤقت على القرص باستخدام posix_fadvise(2) ونتمنى الأفضل.
  3. استخدم Linux AIO بالاقتران مع نظام الملفات XFS ، وفتح الملفات مع علامة O_DIRECT وتجنب المشاكل غير الموثقة .

ومع ذلك ، فإن أيا من هذه الأساليب مثالية. حتى Linux AIO ، عند استخدامه بدون تفكير ، يمكن حظره في استدعاء io_submit() . تم ذكر ذلك مؤخرًا في مقال آخر حول LWN :
"تحتوي واجهة الإدخال / الإخراج غير المتزامنة لنظام Linux على الكثير من النقاد وعدد قليل من المؤيدين ، لكن معظم الناس يتوقعون على الأقل عدم التزامن منه. في الواقع ، يمكن حظر عملية AIO في النواة لعدد من الأسباب في المواقف التي لا يستطيع فيها مؤشر ترابط الاتصال تحمّلها. "
الآن وقد أصبحنا نعرف نقاط الضعف في Linux AIO API ، دعنا ننظر إلى نقاط قوتها.

برنامج بسيط يستخدم Linux AIO


من أجل استخدام Linux AIO ، عليك أولاً تحديد مكالمات النظام الخمسة الضرورية بنفسك - glibc لا توفر لهم.

  1. تحتاج أولاً إلى الاتصال بـ io_setup() لتهيئة بنية aio_context . سيعود kernel بمؤشر غير شفاف إلى الهيكل.
  2. بعد ذلك ، يمكنك استدعاء io_submit() لإضافة متجه "كتل التحكم في الإدخال / الإخراج" إلى قائمة انتظار المعالجة في شكل بنية iocb هيكلية.
  3. الآن ، أخيرًا ، يمكننا استدعاء io_getevents() والانتظار للحصول على إجابة منه في شكل متجه لهياكل io_event الهيكلية - نتائج كل من كتل iocb.

هناك ثمانية أوامر يمكنك استخدامها في iocb. أمرين للقراءة ، وهما للكتابة ، وخياران fsync ، وأمر POLL ، الذي تمت إضافته في الإصدار kernel 4.18 (الأمر الثامن هو NOOP):

 IOCB_CMD_PREAD = 0, IOCB_CMD_PWRITE = 1, IOCB_CMD_FSYNC = 2, IOCB_CMD_FDSYNC = 3, IOCB_CMD_POLL = 5,   /* from 4.18 */ IOCB_CMD_NOOP = 6, IOCB_CMD_PREADV = 7, IOCB_CMD_PWRITEV = 8, 

iocb ، التي يتم تمريرها إلى وظيفة io_submit ، كبيرة جدًا ومصممة للعمل مع القرص. إليك نسخته المبسطة:

 struct iocb { __u64 data;           /* user data */ ... __u16 aio_lio_opcode; /* see IOCB_CMD_ above */ ... __u32 aio_fildes;     /* file descriptor */ __u64 aio_buf;        /* pointer to buffer */ __u64 aio_nbytes;     /* buffer size */ ... } 

بنية io_event الكاملة التي ترجع io_getevents :

 struct io_event { __u64  data; /* user data */ __u64  obj; /* pointer to request iocb */ __s64  res; /* result code for this event */ __s64  res2; /* secondary result */ }; 

مثال برنامج بسيط يقوم بقراءة الملف / etc / passwd باستخدام Linux AIO API:

 fd = open("/etc/passwd", O_RDONLY); aio_context_t ctx = 0; r = io_setup(128, &ctx); char buf[4096]; struct iocb cb = {.aio_fildes = fd,                 .aio_lio_opcode = IOCB_CMD_PREAD,                 .aio_buf = (uint64_t)buf,                 .aio_nbytes = sizeof(buf)}; struct iocb *list_of_iocb[1] = {&cb}; r = io_submit(ctx, 1, list_of_iocb); struct io_event events[1] = {{0}}; r = io_getevents(ctx, 1, 1, events, NULL); bytes_read = events[0].res; printf("read %lld bytes from /etc/passwd\n", bytes_read); 

المصادر الكاملة متوفرة ، بالطبع ، على جيثب . هنا هو الإخراج الضيق لهذا البرنامج:

 openat(AT_FDCWD, "/etc/passwd", O_RDONLY) io_setup(128, [0x7f4fd60ea000]) io_submit(0x7f4fd60ea000, 1, [{aio_lio_opcode=IOCB_CMD_PREAD, aio_fildes=3, aio_buf=0x7ffc5ff703d0, aio_nbytes=4096, aio_offset=0}]) io_getevents(0x7f4fd60ea000, 1, 1, [{data=0, obj=0x7ffc5ff70390, res=2494, res2=0}], NULL) 

كل شيء سار على ما يرام ، ولكن القراءة من القرص لم تكن متزامنة: تم حظر استدعاء io_submit ونفذت كل العمل ، io_getevents تنفيذ وظيفة io_getevents على الفور. قد نحاول أن نقرأ بشكل غير متزامن ، ولكن هذا يتطلب علامة O_DIRECT ، والتي تتجاوز عمليات القرص ذاكرة التخزين المؤقت.

دعونا توضيح كيفية تأمين io_submit على الملفات العادية. فيما يلي مثال مشابه يُظهر إخراج الشرائط نتيجة قراءة كتلة 1 جيجابايت من /dev/zero :

 io_submit(0x7fe1e800a000, 1, [{aio_lio_opcode=IOCB_CMD_PREAD, aio_fildes=3, aio_buf=0x7fe1a79f4000, aio_nbytes=1073741824, aio_offset=0}]) \   = 1 <0.738380> io_getevents(0x7fe1e800a000, 1, 1, [{data=0, obj=0x7fffb9588910, res=1073741824, res2=0}], NULL) \   = 1 <0.000015> 

أنفقت النواة 738 مللي ثانية على مكالمة io_submit و 15 ns فقط على io_getevents . يتصرف بشكل مشابه مع اتصالات الشبكة - يتم تنفيذ كل العمل بواسطة io_submit .


Photo Helix84 CC / BY-SA / 3.0

لينكس AIO والشبكة


يكون تطبيق io_submit متحفظًا تمامًا: إذا لم يتم فتح واصف الملف الذي تم تمريره بعلامة O_DIRECT ، فستقوم الدالة ببساطة بحظر الإجراء المحدد وتنفيذه. في حالة اتصالات الشبكة ، هذا يعني:

  • لحظر الاتصالات ، سينتظر IOCV_CMD_PREAD حزمة استجابة ؛
  • للاتصالات غير المحظورة ، سيعود IOCB_CMD_PREAD الكود -11 (EAGAIN).

يتم استخدام نفس الدلالات أيضًا في استدعاء نظام read() العادي ، لذلك يمكننا القول أن io_submit عند العمل مع اتصالات الشبكة ليست أكثر ذكاءً من مكالمات read() / write() القديمة الجيدة.

من المهم ملاحظة أن طلبات iocb تنفيذها بواسطة النواة بالتسلسل.

على الرغم من أن Linux AIO لن يساعدنا في عمليات غير متزامنة ، إلا أنه يمكن استخدامه لدمج مكالمات النظام في مجموعات.

إذا كان خادم الويب يحتاج إلى إرسال واستقبال البيانات من مئات اتصالات الشبكة ، فإن استخدام io_submit قد يكون فكرة رائعة ، لأنه يتجنب مئات المكالمات وإرسالها. سيؤدي ذلك إلى تحسين الأداء - لا يعد التبديل من مساحة المستخدم إلى النواة والعكس صحيحًا ، خاصة بعد تطبيق تدابير مكافحة Specter و Meltdown .

واحد العازلة
مخازن متعددة
واصف ملف واحد
قراءة ()
readv ()
واصفات الملفات المتعددة
io_submit + IOCB_CMD_PREAD
io_submit + IOCB_CMD_PREADV

لتوضيح تجميع مكالمات النظام في حزم باستخدام io_submit دعنا نكتب برنامجًا صغيرًا يرسل بيانات من اتصال TCP إلى آخر. في أبسط أشكاله (بدون Linux AIO) ، يبدو مثل هذا:

 while True: d = sd1.read(4096) sd2.write(d) 

يمكننا التعبير عن نفس الوظيفة من خلال Linux AIO. سيكون الرمز في هذه الحالة كما يلي:

 struct iocb cb[2] = {{.aio_fildes = sd2,                     .aio_lio_opcode = IOCB_CMD_PWRITE,                     .aio_buf = (uint64_t)&buf[0],                     .aio_nbytes = 0},                    {.aio_fildes = sd1,                    .aio_lio_opcode = IOCB_CMD_PREAD,                    .aio_buf = (uint64_t)&buf[0],                    .aio_nbytes = BUF_SZ}}; struct iocb *list_of_iocb[2] = {&cb[0], &cb[1]}; while(1) { r = io_submit(ctx, 2, list_of_iocb); struct io_event events[2] = {}; r = io_getevents(ctx, 2, 2, events, NULL); cb[0].aio_nbytes = events[1].res; } 

يضيف هذا الرمز وظيفتين إلى io_submit : أولاً طلب كتابة إلى sd2 ، ثم طلب قراءة من sd1. بعد القراءة ، تقوم الشفرة بتصحيح حجم مخزن الكتابة المؤقت وتكرار الحلقة من البداية. هناك خدعة واحدة: في المرة الأولى التي تحدث فيها الكتابة مع وجود مخزن مؤقت بالحجم 0. يعد ذلك ضروريًا لأن لدينا القدرة على الجمع بين كتابة + قراءة في مكالمة io_submit واحدة (ولكن ليس للقراءة + الكتابة).

هل هذا الرمز أسرع من read() العادية read() / write() ؟ ليس بعد. كلا الإصدارين يستخدمان مكالمات النظام: قراءة + كتابة و io_submit + io_getevents. ولكن ، لحسن الحظ ، يمكن تحسين الرمز.

التخلص من io_getevents


في وقت التشغيل io_setup() يخصص kernel عدة صفحات من الذاكرة لهذه العملية. هذه هي الطريقة التي تبدو بها كتلة الذاكرة / خرائط proc //:

 marek:~$ cat /proc/`pidof -s aio_passwd`/maps ... 7f7db8f60000-7f7db8f63000 rw-s 00000000 00:12 2314562     /[aio] (deleted) ... 

تم تخصيص كتلة الذاكرة [aio] (12 كيلو بايت في هذه الحالة) io_setup . يتم استخدامه للمخزن المؤقت الدائري حيث يتم تخزين الأحداث. في معظم الحالات ، لا يوجد سبب للاتصال بـ io_getevents - يمكن الحصول على بيانات إكمال الحدث من المخزن المؤقت الحلقي دون الحاجة إلى التبديل إلى وضع kernel. هنا هو الإصدار الصحيح من الكود:

 int io_getevents(aio_context_t ctx, long min_nr, long max_nr,                struct io_event *events, struct timespec *timeout) {   int i = 0;   struct aio_ring *ring = (struct aio_ring*)ctx;   if (ring == NULL || ring->magic != AIO_RING_MAGIC) {       goto do_syscall;   }   while (i < max_nr) {       unsigned head = ring->head;       if (head == ring->tail) {           /* There are no more completions */           break;       } else {           /* There is another completion to reap */           events[i] = ring->events[head];           read_barrier();           ring->head = (head + 1) % ring->nr;           i++;       }   }   if (i == 0 && timeout != NULL && timeout->tv_sec == 0 && timeout->tv_nsec == 0) {       /* Requested non blocking operation. */       return 0;   }   if (i && i >= min_nr) {       return i;   } do_syscall:   return syscall(__NR_io_getevents, ctx, min_nr-i, max_nr-i, &events[i], timeout); } 

النسخة الكاملة من الكود متاحة على جيثب . تم توثيق واجهة هذا المخزن المؤقت الحلقي بشكل سيئ ؛ قام المؤلف بتكييف الكود من مشروع axboe / fio .

بعد هذا التغيير ، يتطلب إصدارنا من الكود الذي يستخدم Linux AIO مكالمة نظام واحد فقط في حلقة ، مما يجعله أسرع قليلاً من الكود الأصلي باستخدام read + write.


صور القطار صور CC / BY-SA / 2.0

Epoll البديل


مع إضافة IOCB_CMD_POLL إلى إصدار kernel 4.18 ، أصبح من الممكن استخدام io_submit كبديل لـ select / poll / epoll. على سبيل المثال ، يتوقع هذا الرمز بيانات من اتصال الشبكة:

 struct iocb cb = {.aio_fildes = sd,                 .aio_lio_opcode = IOCB_CMD_POLL,                 .aio_buf = POLLIN}; struct iocb *list_of_iocb[1] = {&cb}; r = io_submit(ctx, 1, list_of_iocb); r = io_getevents(ctx, 1, 1, events, NULL); 

كود كامل . هنا هو ناتج الضيق:

 io_submit(0x7fe44bddd000, 1, [{aio_lio_opcode=IOCB_CMD_POLL, aio_fildes=3}]) \   = 1 <0.000015> io_getevents(0x7fe44bddd000, 1, 1, [{data=0, obj=0x7ffef65c11a8, res=1, res2=0}], NULL) \   = 1 <1.000377> 

كما ترون ، نجحت هذه المرة في عدم التزامن: تم تنفيذ io_submit على الفور ، io_getevents لمدة ثانية واحدة ، في انتظار البيانات. يمكن استخدام هذا بدلاً من استدعاء النظام epoll_wait() .

علاوة على ذلك ، يتطلب العمل مع epoll عادةً استخدام مكالمات النظام epoll_ctl. ويحاول مطورو التطبيقات تجنب المكالمات المتكررة لهذه الوظيفة - لفهم الأسباب ، ما عليك سوى قراءة إشارات EPOLLONESHOT و EPOLLET في الدليل . باستخدام io_submit للاستعلام عن الاتصالات ، يمكنك تجنب هذه الصعوبات ومكالمات النظام الإضافية. فقط أضف الاتصالات إلى متجه iocb ، اتصل بـ io_submit مرة واحدة وانتظر التنفيذ. كل شيء بسيط جدا.

ملخص


في هذا المنشور ، قمنا بتغطية Linux AIO API. تم تصميم واجهة برمجة التطبيقات هذه في الأصل للعمل مع القرص ، ولكنها تعمل أيضًا مع اتصالات الشبكة. ومع ذلك ، بخلاف المكالمات العادية () + write () ، فإن استخدام io_submit يسمح لك بتجميع مكالمات النظام وبالتالي زيادة الأداء.

بدءًا من الإصدار kernel 4.18 ، يمكن استخدام io_submit io_getevents في حالة اتصالات الشبكة لأحداث النموذج POLLIN و POLLOUT. هذا بديل ل epoll() .

أستطيع أن أتخيل خدمة شبكة تستخدم فقط io_submit io_getevents بدلاً من المجموعة القياسية من القراءة والكتابة و epoll_ctl و epoll_wait. في هذه الحالة ، يمكن أن تعطي ميزة تجميع نظام المكالمات في io_submit ميزة كبيرة ، سيكون مثل هذا الخادم أسرع بكثير.

لسوء الحظ ، حتى بعد التحسينات الأخيرة التي طرأت على Linux AIO API ، تستمر المناقشات حول فائدتها. من المعروف أن لينوس يكرهه :

"تعد AIO مثالًا فظيعًا على التصميم المرتفع للركبة ، حيث يكمن العذر الرئيسي في ذلك:" لقد توصل هذا إلى أشخاص آخرين أقل موهبة ، لذلك يتعين علينا الامتثال للتوافق حتى يتمكن مطورو قواعد البيانات (الذين نادراً ما يتذوقون ذلك) من استخدامه. " لكن AIO كان دائمًا شديد الملل. "

بذلت عدة محاولات لإنشاء واجهة أفضل لتجميع المكالمات وعدم التزامن ، لكنها كانت تفتقر إلى رؤية مشتركة. على سبيل المثال ، تسمح الإضافة الحديثة لـ sendto (MSG_ZEROCOPY) بنقل بيانات غير متزامن حقًا ، لكنها لا تنص على التجميع. يوفر io_submit للتجميع ، ولكن ليس التزامن. والأسوأ من ذلك - هناك حاليًا ثلاث طرق لتقديم أحداث غير متزامنة على Linux: الإشارات و io_getevents و MSG_ERRQUEUE.

على أي حال ، من الرائع أن تكون هناك طرق جديدة لتسريع عمل خدمات الشبكة.

Source: https://habr.com/ru/post/ar439972/


All Articles