رفتن به محتوا
بایت

00001010

چگونه میلیون‌ها سوکت TCP را بدون سرخ کردن CPU هندل کنیم؟

راز هندل کردن میلیون‌ها اتصال هم‌زمان

۱۱ دقیقه مطالعه

اگر تا به حال از API‌های سطح پایین شبکه در زبان C استفاده کرده باشید، حتماً با مجموعهٔ توابع استاندارد socket، bind، listen، connect، accept، send، recv و ... مواجه شده‌اید. این توابع بخشی از رابط Berkeley sockets هستند که در اکثر سیستم‌عامل‌ها، به‌عنوان روش اصلی برای ساختن و استفاده از سوکت‌های شبکه استفاده می‌شود. حالا فرض کنید قصد پیاده‌سازی یک سرور را روی سیستم‌عامل گنو/لینوکس یا یکی از سیستم‌عامل‌های خانوادهٔ BSD داریم. مستقل از هر زبان برنامه‌نویسی و abstraction‌ای که استفاده کنیم، در نهایت تمام عملیات‌های مربوط به شبکه از طریق system call‌های کم و بیش متناظر با Berkeley sockets به کرنل داده می‌شوند. پس بیایید برای بررسی بهتر، آن برنامه را به صورت شبه‌کدی نزدیک به زبان C بنویسیم. (برای درک بهتر، پارامترهای توابع ساده‌سازی شده‌اند و از برخی خطاها و حالات خاص ممکن چشم‌پوشی شده است):

char buffer[4096];
int sockfd = socket(...);
bind(sockfd, "0.0.0.0:8080");
listen(sockfd, ...);
 
while (true) {
	int connfd = accept(sockfd, ...);
	
	// Handle the connection
	ssize_t n = recv(connfd, buffer, ...);
	if (n > 0) {
		send(connfd, buffer, n);
	}
	
	// Close the connection
	close(connfd);
}

این برنامه، پیاده‌سازی یک سرور ساده است که صرفاً بخشی از اول پیامی که از طرف هر کلاینت ارسال می‌شود را به آن برمی‌گرداند و سپس اتصال را می‌بندد. سه دستور socket، bind و listen، سوکت سمت سرور را روی پورت ۸۰۸۰ برای گوش دادن به اتصال‌های جدید TCP آماده می‌کنند. سپس در یک حلقهٔ while، با دستور accept یکی از اتصال‌های جدید را قبول می‌کنیم و سپس تا حداکثر ۴ کیلوبایت داده را از آن می‌خوانیم (دستور recv) و سعی می‌کنیم همان را به کلاینت (دستور send). در نگاه اول شاید به نظر برسد که این کد می‌تواند به چندین کلاینت به صورت هم‌زمان پاسخ‌گو باشد. اما اصلاً این‌طور نیست! دستوراتی مانند accept و recv و send ممکن است در صورت آماده نبودن برای انجام عملیات، روند اجرای برنامه را موقتاً متوقف کنند. مثلاً اگر هنوز اتصالی از طرف هیچ کلاینتی برقرار نشده بود، دستور accept صبر می‌کند تا اولین اتصال برقرار شود. یا اگر هنوز داده‌ای ارسال نشده بود، دستور recv صبر می‌کند تا قطعات داده از سمت کلاینت ارسال شوند (مگر اینکه اتصال بسته شود یا خطایی رخ دهد). این اتفاق باعث می‌شود که فرضاً اگر یک کلاینت، اتصالی را باز کند ولی برای چند ثانیه داده‌ای ارسال نکند، برنامهٔ ما در اجرای دستور recv متوقف بماند و قابلیت قبول کردن و رسیدگی به اتصال‌های جدید را نداشته باشد.

فرض کنید این‌گونه مشکلات در برنامه‌های پیچیده‌تر مانند سرورهای HTTP که باید قابلیت خدمت‌رسانی به هزاران یا حتی میلیون‌ها اتصال هم‌زمان را داشته باشند، چقدر ناگوار خواهد بود. پس به راستی آن‌ها چگونه این مشکل را حل می‌کنند؟!

یک راه‌حل ساده، ایجاد یک thread به‌ازای هر اتصال است. یعنی بعد از خروج دستور accept، هر دفعه یک thread جدید درست کنیم تا فقط به همان یک اتصال رسیدگی کند و از نگه‌داشتن بقیهٔ روند برنامه جلوگیری کند. این روش از لحاظ مفهومی بسیار ساده است و برای برنامه‌های آموزشی با نیازمندی‌های پایین کارآمد است. اما مشکل از جایی آغاز می‌شود که تعداد درخواست‌ها بالا رفته و نیاز به ساختن تعداد زیادی thread باشد. از آنجایی که این thread‌ها توسط سیستم‌عامل مدیریت می‌شوند و نیاز به حجم قابل‌توجهی از منابع سیستمی برای مدیریتشان دارند، تعداد بسیار زیاد آن‌ها باعث افزایش مصرف حافظهٔ اصلی می‌شود و همچنین تعداد ‌ها را بسیار بالا می‌برد. از این رو، باید به دنبال راهی باشیم تا با استفاده از تعداد محدودی thread، بتوانیم برای چندین سوکت هم‌زمان صبر کنیم.

قبل از رفتن سراغ اصل مطلب، می‌خواهم به یک راه‌حل ساده‌لوحانه اشاره کنم. سوکت‌ها این قابلیت را دارند که حالت non-blocking قرار بگیرند. یعنی هیچ‌کدام از دستورات اجرایی روی آن‌ها، منتظر I/O نمی‌مانند و در صورتی که در آن لحظه قابل انجام نباشند، خطای EWOULDBLOCK را بازمی‌گردانند که یعنی اجرای این دستور نیازمند توقف برنامه و صبر کردن بوده و برنامه باید بعد از آماده شدن سوکت برای اجرای عملیات، دوباره آن دستور را فراخوانی کند. یک راه‌حل ممکن برای مشکل مطرح‌شده، این است که تعداد زیادی اتصال را بپذیریم (با accept) و سپس آن‌ها را در حالت non-blocking قرار دهیم و پشت سر هم در یک حلقه تلاش برای خواندن از آن‌ها کنیم. سپس اگر خطای EWOULDBLOCK برای هرکدام از آن‌ها برگردانده شد، از آن سوکت در آن مرتبهٔ اجرای حلقه صرف‌نظر می‌کنیم. این راه‌حل، به‌علت اشغال دائم پردازنده و سیستم‌عامل در یک حلقهٔ باطل (الگوی polling) معمولاً کاربرد عمومی ندارد. استفاده از توقف‌های کوچک بین هر دور حلقه نیز به‌علت ایجاد تأخیر بی‌مورد در پردازش درخواست‌ها راهکار مطلوبی به شمار نمی‌آید.

منجی ما: select

ریشهٔ اصلی مشکل، از آنجایی می‌آید که هرکدام از دستوراتی که تاکنون بررسی کردیم، صرفاً روی یک عملیات خاص روی یک سوکت خاص عمل می‌کنند. در این حالت، وقتی تعداد سوکت‌ها زیاد می‌شود، یا باید بین آن‌ها دائم چرخ باطل بزنیم تا ببینیم کدام سوکت‌ها آمادهٔ اجرای عملیات (مثلاً پذیرش اتصال، خواندن و یا نوشتن) هستند و چرخه‌های پردازنده را به هدر بدهیم، یا اینکه احتمال از دست دادن یا تأخیر زیاد در اجرای عملیات‌ها روی سوکت‌های دیگر را بپذیریم.

از آنجایی که سیستم‌عامل همواره راجع به حالت سوکت‌های مختلف و آمادگی آن‌ها برای عملیات‌های مختلف اطلاع دارد، از لحاظ نظری می‌تواند به ما این امکان را بدهد که هم‌زمان، مثلاً برای خواندن از چند سوکت مختلف هم‌زمان صبر کنیم، و هرکدام که آمادهٔ خواندن شدند، به برنامهٔ ما اطلاع بدهد تا کار خود را ادامه دهد. خوشبختانه توسعه‌دهندگان BSD، به فکر این مسئله بودند و یک دستور دیگر به نام select را طراحی کردند. با کمی ساده‌سازی می‌توان در نظر گرفت که با این دستور، برنامهٔ شما به سیستم‌عامل می‌گوید:

من دوست دارم روی این مجموعه از عملیات خواندن و روی این یکی مجموعه عملیات نوشتن را انجام دهم. هر وقت حداقل یکی از آن‌ها آمادهٔ آن کار بودند، بهم اطلاع بده! ❤️ و در ضمن، بیشتر از tt ثانیه هم طولش نده!

این دستور (با کمی ساده‌سازی)، دو از سوکت‌ها و یک مقدار timeout را ورودی می‌گیرد. یکی برای خواندن و دیگری برای نوشتن و فقط وقتی تمام می‌شود که یا حداقل یکی از عملیات‌های خواسته‌شده قابل‌انجام شوند، یا timeout خواسته‌شده گذشته باشد.

برای مثال می‌توانیم شبه‌کد بالا را طوری ارتقا دهیم که برای خواندن از اتصال‌ها، جداگانه صبر نکند و به‌محض آمادگی هرکدامشان، سریعاً از آن بخواند و ادامهٔ کار را انجام دهد:

char buffer[4096];
int sockfd = socket(...);
int max_fd = sockfd;
 
bind(sockfd, "0.0.0.0:8080");
listen(sockfd, ...);
 
fd_set connections;
FD_ZERO(&connections);
FD_SET(sockfd, &connections);
 
while (true) {
    fd_set ready = connections;
    select(max_fd + 1, &ready, NULL, NULL, NULL);
 
    // Check for new connections
    if (FD_ISSET(sockfd, &ready)) {
        int connfd = accept(sockfd, ...);
        FD_SET(connfd, &connections);
        if (connfd > max_fd) {
	        max_fd = connfd;
        }
    }
 
    // Handle existing connections
    for (int fd = 0; fd <= max_fd; fd++) {
        if (fd == sockfd || !FD_ISSET(fd, &ready))
            continue;
 
        ssize_t n = recv(fd, buffer, sizeof(buffer), ...);
 
        if (n <= 0) {
            // Close the connection
            close(fd);
            FD_CLR(fd, &connections);
            continue;
        }
 
        send(fd, buffer, n, ...);
    }
}

آن مجموعهٔ سوکت‌ها که به آن‌ها اشاره شد، توسط نوع fd_set نشان داده می‌شود. ما یک مجموعه از تمام سوکت‌هایی که تمایل به خواندن از آن‌ها داریم را در connections نگه می‌داریم. سپس در هر دور حلقه، یک کپی از آن مجموعه برای ارسال به دستور select ایجاد می‌کنیم. دلیل این کار این است که این دستور، مجموعه‌های ورودی خودش را تغییر می‌دهد و فقط سوکت‌هایی که آماده هستند را در آن‌ها نگه می‌دارد و بقیه را حذف می‌کند.

از آنجایی که حالت «قابل خواندن» روی یک سوکت در حال گوش دادن به معنی آماده بودن یک اتصال جدید است، می‌توانیم از همین مکانیسم برای accept کردن بدون درنگ اضافه نیز استفاده کنیم. به این صورت که اگر سوکت اولیه در مجموعهٔ سوکت‌های آمادهٔ خواندن بود، دستور accept را اجرا می‌کنیم. سپس تمام سوکت‌های اتصال را بررسی می‌کنیم و اگر قابل خواندن بودند، از آن‌ها می‌خوانیم و ادامهٔ کار لازم را انجام می‌دهیم.

لازم به ذکر است که در یک برنامهٔ واقعی، لازم است امکان درنگ در عملیات نوشتن (send) نیز در نظر گرفته شود. همچنین، حتی بعد از اطلاع از آمادگی یک عملیات بعد از select، در شرایط خاصی ممکن است باز هم نیاز به توقف برنامه باشد و در سناریوهای جدی‌تر باید این موارد نیز لحاظ شوند. اما اینجا برای سادگی فرض می‌کنیم این اتفاقات رخ نمی‌دهند.

با استفاده از دستور select، می‌توان در حد صدها اتصال را به راحتی مدیریت کرد. اما با افزایش بیشتر تعداد اتصالات، محدودیت‌های آن آشکار می‌شود. بزرگ‌ترین ضعف این دستور، در نحوهٔ نمایش مجموعهٔ سوکت‌ها در ساختار fd_set است. در پیاده‌سازی لینوکس، این ساختار مجموعهٔ سوکت‌ها را در یک bitset نگه‌داری می‌کند. از آنجایی که هر سوکت یک file descriptor خاص خود را دارد که یک عدد صحیح است، می‌توان با روشن کردن بیت مربوط به هر سوکت، آن را در مجموعه انتخاب کرد. به‌دلیل اینکه تعداد بیت‌های موجود در این bitset محدود است (معمولاً ۱۰۲۴ بیت)، نمی‌توان file descriptor‌هایی با مقدار عددی بیشتر از ۱۰۲۳ را انتخاب کرد. از طرف دیگر، در برنامهٔ خودمان نیز باید تمام سوکت‌ها را پشت سر هم بررسی کنیم که آیا در مجموعهٔ سوکت‌های آماده وجود دارند یا نه. این کار پیچیدگی زمانی O(n)O(n) دارد که n تعداد کل سوکت‌هاست، نه فقط سوکت‌های آماده. علاوه بر این دلایل، چون خروجی این دستور مجموعه‌های ورودی را تغییر می‌داد، پیش از هر بار فراخوانی این دستور نیاز به ساختن مجدد ساختار fd_set بود.

به‌دلیل این محدودیت‌ها، مدتی بعد از آن دستور جدیدی رونمایی شد که برخی از این مشکلات را حل می‌کرد.

poll

در این دستور جدید که poll نام داشت، ورودی مجموعه نه به صورت یک bitset، بلکه به صورت یک لیست از pollfd ارسال می‌شود که کم و بیش به این صورت تعریف شده است:

struct pollfd {
    int   fd;
    short events;
    short revents;
};

به این صورت که شمارهٔ file descriptor در fd، نوع عملیاتی که منتظر آمادگی آن هستیم (خواندن، نوشتن یا ...) در events و خروجی دستور در revents قرار خواهد گرفت. از جهات دیگر، فراخوانی این دستور شبیه select است.

این ساختار جدید، برخی از مشکلاتی که هنگام استفاده از دستور select مطرح می‌شوند را حل کرد. در نهایت نیز هر دو دستور به استاندارد راه یافتند و امروزه تقریباً در تمام سیستم‌عامل‌های واقعی به صورت system call قابل‌استفاده هستند.

با این حال، حتی poll نیز بعد از چند هزار اتصال شروع به نشان‌دادن ضعف‌هایش می‌کند. مشکل از آنجایی ناشی می‌شود که بین هر بار فراخوانی این system call، سیستم‌عامل نمی‌داند که ورودی آن تغییر کرده است یا نه. یعنی هر دفعه، کرنل باید چندین هزار ورودی آن را بررسی کند، زیرا این آرایه در فضای کاربر قرار دارد و ممکن است بین فراخوانی‌های مختلف، بدون اطلاع مستقیم کرنل تغییر کند. در واقع همان مشکل پیمایش خطی با پیچیدگی O(n)O(n) هنوز در سطح کرنل وجود دارد: هر بار باید همهٔ سوکت‌ها بررسی شوند، نه فقط سوکت‌های آماده. در ادامه به دوای این درد می‌پردازیم!

epoll, kqueue, ...

ایدهٔ اصلی رفع این مشکل بسیار ساده است: ساختار دادهٔ مجموعهٔ سوکت‌ها را به کرنل منتقل کنید و تمام عملیات‌هایی که آن را تغییر می‌دهند را به صورت system call دربیاورید.

با این روش، کرنل همواره از محتویات آن مجموعه اطلاع دارد و لازم نیست هنگام فراخوانی system call تک‌تک آن‌ها را بررسی کند؛ می‌تواند مستقیماً وقتی فعالیت I/O روی سوکت‌های مشخص‌شده رخ می‌دهد، تکلیف ما را مشخص کند.

کرنل‌های لینوکس و سیستم‌عامل‌های خانوادهٔ BSD هر دو این الگو را در قالب API‌های خاص خود پیاده‌سازی کردند. با اینکه ظاهر این API‌ها با هم فرق قابل‌توجهی دارد، ایدهٔ اصلی پشتشان یکسان است. راه‌حل‌های لینوکس و BSD به ترتیب epoll و kqueue نام دارند.

در ادامه به بررسی بخش‌های اصلی رابط epoll که به‌عنوان چند system call لینوکس در دسترس است می‌پردازیم.

از اولین دستور این رابط، برای ساختن ساختار دادهٔ سمت کرنل استفاده می‌شود:

int epoll_create1(int flags);

این دستور یک file descriptor برمی‌گرداند که برای اشاره به آن ساختار داده استفاده می‌شود.

دومین دستور، به ما اجازهٔ ایجاد تغییرات در آن مجموعه را می‌دهد:

int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);

با هر بار فراخوانی این دستور، می‌توان یک سوکت (fd) را به آن مجموعه اضافه یا کم کرد یا در پارامترهای آن تغییراتی ایجاد کرد. ساختار epoll_event دارای یک فیلد events است که اجازهٔ مشخص کردن نوع عملیاتی که مایل به انجام آن هستیم را به ما می‌دهد. (مانند ساختار pollfd که بالاتر به آن اشاره شد)

می‌توان گفت دستور سوم، اصلی‌ترین دستور این رابط است که اصل کار صبر کردن را انجام می‌دهد.

int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);

برخلاف poll و select که خروجی خود را با اعمال تغییر در ورودیشان به‌روزرسانی می‌کردند، این دستور خروجی خود را در آرایهٔ events ذخیره می‌کند. همچنین مانند دو دستور POSIX، پارامتر timeout نیز به چشم می‌خورد.

علاوه بر این سه دستور، چند دستور دیگر مربوط به epoll نیز وجود دارند که اغلب نسخه‌های متفاوتی از همین سه دستور هستند. برای خواندن بیشتر راجع به آن‌ها، می‌توانید به manpage مربوط به epoll(7) مراجعه کنید!

در نهایت، این API‌ها به استاندارد جدید نرم‌افزارهای سمت‌سرور تبدیل شدند و در کنار بسیاری از فناوری‌های دیگر، اجازهٔ رسیدگی به میلیون‌ها اتصال TCP از روی یک سرور را می‌دهند. برنامه‌ها، کتابخانه‌ها و runtime‌هایی مانند:

تنها بخش کوچکی از نرم‌افزارهایی هستند که از رابط epoll و kqueue و مشابه آن‌ها استفاده می‌کنند. نکتهٔ جالب اینجاست که در بسیاری از runtime‌های مدرن async I/O، می‌توان از مفهومی مشابه thread استفاده کرد؛ در حالی که در زیر پوست برنامه، runtime async I/O کار سخت را برای ما انجام می‌دهد و برنامهٔ ما را حتی روی یک thread واقعی قادر به رسیدگی به تعداد بسیار زیادی از درخواست‌ها می‌کند. به چنین task‌های سبک‌وزن و زمان‌بندی‌شده در فضای کاربر، بسته به runtime و مدل اجرایی، گاهی اصطلاحاتی مانند coroutine یا green thread نیز اطلاق می‌شود. برای مثال در زبان Rust و با runtime Tokio می‌توان به این صورت این برنامه را نوشت (با این تفاوت که کل بایت‌های ارسال‌شده بازتاب می‌شوند):

use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
 
#[tokio::main(flavor = "current_thread")]
async fn main() -> std::io::Result<()> {
    let listener = TcpListener::bind("0.0.0.0:8080").await?;
 
    loop {
        let (mut socket, _) = listener.accept().await?;
 
        tokio::spawn(async move {
            let mut buffer = [0; 4096];
 
            loop {
                let bytes_read = socket.read(&mut buffer).await.unwrap();
 
                if bytes_read == 0 {
                    break;
                }
 
                socket.write_all(&buffer[..bytes_read]).await.unwrap();
            }
        });
    }
}

همان‌طور که می‌بینید، ساختار این برنامه مانند اولین راه‌حل ساده‌انگارانه است که از thread‌های اضافه استفاده می‌کرد. همچنین اینجا هنگام فراخوانی دستوراتی که ممکن است منتظر بمانند، از کلمهٔ کلیدی await استفاده شده است. این ساختار به ما اجازه می‌دهد که تظاهر کنیم از همان الگوی سادهٔ thread استفاده می‌کنیم، در حالی که اتفاقی که واقعاً می‌افتد، کمی پیچیده‌تر و بسیار بهینه‌تر است.

مطالب مرتبط