לדלג לתוכן

שרת TCP מתקדם - advanced TCP server

הבעיה

בפרק 11.1 כתבנו שרת הד שעובד, אבל הוא מטפל בלקוח אחד בכל פעם. כל עוד השרת עסוק בלולאת read/write עם לקוח אחד, לקוחות אחרים שמנסים להתחבר חייבים לחכות. בעולם האמיתי, שרת צריך לטפל במאות או אלפי לקוחות במקביל.

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


גישה 1: תהליך לכל חיבור - fork per connection

הרעיון: אחרי accept, השרת עושה fork. תהליך הילד מטפל בלקוח, ותהליך האב חוזר ל-accept לקבל לקוחות חדשים.

למדנו על fork בפרק 5.2. כל תהליך ילד מקבל עותק של כל ה-fd-ים של האב, כולל הסוקט החדש של הלקוח.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define PORT 8080
#define BUFFER_SIZE 1024

void handle_client(int client_fd, struct sockaddr_in *client_addr)
{
    char client_ip[INET_ADDRSTRLEN];
    inet_ntop(AF_INET, &client_addr->sin_addr, client_ip, sizeof(client_ip));
    printf("[pid %d] handling client %s:%d\n", getpid(), client_ip,
           ntohs(client_addr->sin_port));

    char buffer[BUFFER_SIZE];
    ssize_t n;
    while ((n = read(client_fd, buffer, sizeof(buffer))) > 0) {
        write(client_fd, buffer, n);
    }

    printf("[pid %d] client disconnected\n", getpid());
    close(client_fd);
    exit(0);  /* תהליך הילד מסיים */
}

int main(void)
{
    /* טיפול ב-SIGCHLD - ניקוי אוטומטי של תהליכי ילד */
    signal(SIGCHLD, SIG_IGN);

    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) {
        perror("socket");
        exit(1);
    }

    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    server_addr.sin_addr.s_addr = INADDR_ANY;

    if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind");
        exit(1);
    }

    if (listen(server_fd, 10) == -1) {
        perror("listen");
        exit(1);
    }
    printf("fork server listening on port %d...\n", PORT);

    while (1) {
        struct sockaddr_in client_addr;
        socklen_t client_len = sizeof(client_addr);
        int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
        if (client_fd == -1) {
            perror("accept");
            continue;
        }

        pid_t pid = fork();
        if (pid == -1) {
            perror("fork");
            close(client_fd);
            continue;
        }

        if (pid == 0) {
            /* תהליך ילד - מטפל בלקוח */
            close(server_fd);  /* הילד לא צריך את סוקט השרת */
            handle_client(client_fd, &client_addr);
            /* לא מגיעים לכאן - handle_client קורא ל-exit */
        }

        /* תהליך אב - חוזר ל-accept */
        close(client_fd);  /* האב לא צריך את סוקט הלקוח */
    }

    return 0;
}

יתרונות:
- פשוט ליישום
- כל לקוח מבודד בתהליך נפרד - קריסה של לקוח אחד לא משפיעה על אחרים
- טוב לשרתים עם מעט לקוחות שכל אחד דורש הרבה עיבוד

חסרונות:
- fork יקר - יצירת תהליך חדש דורשת משאבים (זיכרון, זמן)
- לא סקיילבילי - עם 10,000 לקוחות נצטרך 10,000 תהליכים
- תקשורת בין הלקוחות מסובכת (צריך IPC)


גישה 2: תהליכון לכל חיבור - thread per connection

הרעיון דומה לfork, אבל במקום תהליך חדש יוצרים thread (תהליכון). למדנו על threads בפרק 5.8. הם קלים יותר מתהליכים כי חולקים את אותו מרחב זיכרון.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define PORT 8080
#define BUFFER_SIZE 1024

struct client_info {
    int fd;
    struct sockaddr_in addr;
};

void *handle_client(void *arg)
{
    struct client_info *info = (struct client_info *)arg;
    int client_fd = info->fd;

    char client_ip[INET_ADDRSTRLEN];
    inet_ntop(AF_INET, &info->addr.sin_addr, client_ip, sizeof(client_ip));
    printf("[thread %lu] handling client %s:%d\n",
           (unsigned long)pthread_self(), client_ip, ntohs(info->addr.sin_port));

    char buffer[BUFFER_SIZE];
    ssize_t n;
    while ((n = read(client_fd, buffer, sizeof(buffer))) > 0) {
        write(client_fd, buffer, n);
    }

    printf("[thread %lu] client disconnected\n", (unsigned long)pthread_self());
    close(client_fd);
    free(info);
    return NULL;
}

int main(void)
{
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) {
        perror("socket");
        exit(1);
    }

    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    server_addr.sin_addr.s_addr = INADDR_ANY;

    if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind");
        exit(1);
    }

    if (listen(server_fd, 10) == -1) {
        perror("listen");
        exit(1);
    }
    printf("threaded server listening on port %d...\n", PORT);

    while (1) {
        struct client_info *info = malloc(sizeof(struct client_info));
        if (!info) {
            perror("malloc");
            continue;
        }

        socklen_t client_len = sizeof(info->addr);
        info->fd = accept(server_fd, (struct sockaddr *)&info->addr, &client_len);
        if (info->fd == -1) {
            perror("accept");
            free(info);
            continue;
        }

        pthread_t thread;
        if (pthread_create(&thread, NULL, handle_client, info) != 0) {
            perror("pthread_create");
            close(info->fd);
            free(info);
            continue;
        }
        pthread_detach(thread);  /* לא צריך join - ה-thread מנקה את עצמו */
    }

    return 0;
}

קומפילציה (צריך להוסיף -pthread):

gcc -o threaded_server threaded_server.c -pthread

שימו לב: מקצים struct client_info על הheap (עם malloc) ולא על הstack. למה? כי אם נגדיר אותו כמשתנה מקומי בלולאה, הלולאה תדרוס אותו בסיבוב הבא - לפני שהthread הספיק לקרוא אותו. זה race condition קלאסי.

יתרונות:
- קל יותר מfork - threads זולים יותר מתהליכים
- תקשורת בין threads קלה (חולקים זיכרון)

חסרונות:
- עדיין thread אחד לכל לקוח - לא סקיילבילי ל-10,000 לקוחות
- באגים של concurrency (race conditions, deadlocks)
- קריסה של thread אחד יכולה להפיל את כל השרת


גישה 3: ריבוב קלט/פלט עם select - I/O multiplexing

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

select מאפשר לנו לחכות עד שאחד (או יותר) מתוך רשימת fd-ים מוכן לקריאה או כתיבה. במקום read שחוסם אותנו על fd אחד, select חוסם אותנו עד שאיזשהו fd מוכן.

איך זה עובד

  1. יוצרים קבוצה (fd_set) של fd-ים שרוצים לנטר
  2. קוראים ל-select - הוא חוסם עד שלפחות fd אחד מוכן
  3. בודקים אילו fd-ים מוכנים ומטפלים בהם
  4. חוזרים לצעד 1

המקרואים של fd_set

fd_set read_fds;

FD_ZERO(&read_fds);          // אפס את הקבוצה
FD_SET(fd, &read_fds);       // הוסף fd לקבוצה
FD_CLR(fd, &read_fds);       // הסר fd מהקבוצה
FD_ISSET(fd, &read_fds);     // בדוק אם fd בקבוצה (מחזיר != 0 אם כן)

הפונקציה select

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);
  • nfds - מספר הfd הגבוה ביותר + 1 (select סורק fd-ים מ-0 עד nfds-1)
  • readfds - קבוצת fd-ים שרוצים לנטר לקריאה (NULL אם לא רלוונטי)
  • writefds - קבוצת fd-ים שרוצים לנטר לכתיבה (NULL אם לא רלוונטי)
  • exceptfds - קבוצת fd-ים שרוצים לנטר לחריגות (NULL אם לא רלוונטי)
  • timeout - כמה זמן לחכות (NULL = לנצח)

חשוב: select משנה את הקבוצות! אחרי שהוא חוזר, רק הfd-ים שמוכנים נשארים בקבוצה. לכן צריך לבנות מחדש את הקבוצה לפני כל קריאה ל-select.

דוגמה מלאה - שרת הד עם select

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/select.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define PORT 8080
#define MAX_CLIENTS 100
#define BUFFER_SIZE 1024

int main(void)
{
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) {
        perror("socket");
        exit(1);
    }

    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    server_addr.sin_addr.s_addr = INADDR_ANY;

    if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind");
        exit(1);
    }

    if (listen(server_fd, 10) == -1) {
        perror("listen");
        exit(1);
    }
    printf("select server listening on port %d...\n", PORT);

    /* מערך של fd-ים של לקוחות */
    int clients[MAX_CLIENTS];
    for (int i = 0; i < MAX_CLIENTS; i++)
        clients[i] = -1;  /* -1 = ריק */

    int max_fd = server_fd;

    while (1) {
        /* בניית קבוצת הfd-ים מחדש בכל סיבוב */
        fd_set read_fds;
        FD_ZERO(&read_fds);
        FD_SET(server_fd, &read_fds);  /* תמיד מנטרים את סוקט השרת */

        for (int i = 0; i < MAX_CLIENTS; i++) {
            if (clients[i] != -1) {
                FD_SET(clients[i], &read_fds);
                if (clients[i] > max_fd)
                    max_fd = clients[i];
            }
        }

        /* חכה עד שfd כלשהו מוכן */
        int ready = select(max_fd + 1, &read_fds, NULL, NULL, NULL);
        if (ready == -1) {
            perror("select");
            break;
        }

        /* בדיקה: האם סוקט השרת מוכן? (= לקוח חדש מחכה) */
        if (FD_ISSET(server_fd, &read_fds)) {
            struct sockaddr_in client_addr;
            socklen_t client_len = sizeof(client_addr);
            int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
            if (client_fd != -1) {
                char client_ip[INET_ADDRSTRLEN];
                inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip));
                printf("new client: %s:%d (fd %d)\n",
                       client_ip, ntohs(client_addr.sin_port), client_fd);

                /* מוסיפים את הלקוח למערך */
                int added = 0;
                for (int i = 0; i < MAX_CLIENTS; i++) {
                    if (clients[i] == -1) {
                        clients[i] = client_fd;
                        added = 1;
                        break;
                    }
                }
                if (!added) {
                    printf("too many clients, rejecting\n");
                    close(client_fd);
                }
            }
        }

        /* בדיקה: האם לקוח כלשהו שלח נתונים? */
        for (int i = 0; i < MAX_CLIENTS; i++) {
            if (clients[i] != -1 && FD_ISSET(clients[i], &read_fds)) {
                char buffer[BUFFER_SIZE];
                ssize_t n = read(clients[i], buffer, sizeof(buffer));
                if (n <= 0) {
                    /* לקוח התנתק */
                    printf("client fd %d disconnected\n", clients[i]);
                    close(clients[i]);
                    clients[i] = -1;
                } else {
                    /* echo */
                    write(clients[i], buffer, n);
                }
            }
        }
    }

    close(server_fd);
    return 0;
}

יתרונות:
- thread אחד מטפל בהרבה לקוחות
- אין תקורה של יצירת threads/תהליכים
- אין בעיות concurrency

חסרונות:
- fd_set מוגבל ל-FD_SETSIZE (בדרך כלל 1024) - אי אפשר לנטר יותר מזה
- ביצועים O(n) - בכל סיבוב צריך לסרוק את כל הfd-ים
- צריך לבנות מחדש את הקבוצה בכל קריאה ל-select


גישה 4: ריבוב עם poll

poll פותרת חלק מהבעיות של select. במקום bitmask (fd_set), היא משתמשת במערך של מבנים:

#include <poll.h>

struct pollfd {
    int fd;         /* file descriptor */
    short events;   /* אירועים שאנחנו מחכים להם */
    short revents;  /* אירועים שקרו (ממולא על ידי poll) */
};

האירועים הנפוצים:
- POLLIN - יש נתונים לקריאה
- POLLOUT - אפשר לכתוב
- POLLERR - שגיאה
- POLLHUP - הצד השני סגר את החיבור

דוגמה קצרה

struct pollfd fds[MAX_CLIENTS + 1];
int nfds = 0;

/* סוקט השרת */
fds[0].fd = server_fd;
fds[0].events = POLLIN;
nfds = 1;

while (1) {
    int ready = poll(fds, nfds, -1);  /* -1 = חכה לנצח */
    if (ready == -1) {
        perror("poll");
        break;
    }

    /* בדיקת סוקט השרת */
    if (fds[0].revents & POLLIN) {
        int client_fd = accept(server_fd, NULL, NULL);
        if (client_fd != -1) {
            fds[nfds].fd = client_fd;
            fds[nfds].events = POLLIN;
            nfds++;
        }
    }

    /* בדיקת לקוחות */
    for (int i = 1; i < nfds; i++) {
        if (fds[i].revents & POLLIN) {
            char buffer[1024];
            ssize_t n = read(fds[i].fd, buffer, sizeof(buffer));
            if (n <= 0) {
                close(fds[i].fd);
                /* הזזת האחרון למקום הנוכחי */
                fds[i] = fds[nfds - 1];
                nfds--;
                i--;
            } else {
                write(fds[i].fd, buffer, n);
            }
        }
    }
}

יתרונות על select:
- אין מגבלת FD_SETSIZE - אפשר לנטר כמה fd-ים שרוצים
- לא צריך לבנות מחדש את המערך (poll לא משנה את events, רק ממלא revents)

חסרונות:
- עדיין O(n) - הקרנל סורק את כל המערך בכל קריאה
- פחות יעיל מ-epoll עם הרבה fd-ים


גישה 5: epoll - ספציפי ללינוקס, הכי יעיל

epoll הוא מנגנון I/O multiplexing ספציפי ללינוקס שפותר את בעיית הביצועים של select ו-poll. זה המנגנון ש-nginx, Node.js, Redis, ושרתים עתירי ביצועים אחרים משתמשים בו.

למה epoll טוב יותר?

ב-select וב-poll, בכל קריאה הקרנל צריך לסרוק את כל הfd-ים כדי לבדוק מי מוכן. זה O(n) לכל קריאה. אם יש 10,000 fd-ים אבל רק 5 מוכנים, הקרנל עדיין סורק את כל 10,000.

ב-epoll, הקרנל שומר רשימה פנימית של fd-ים מנוטרים. כשfd הופך למוכן, הקרנל מוסיף אותו לרשימת "מוכנים". כש-epoll_wait נקרא, הוא פשוט מחזיר את הרשימה הזו - בלי לסרוק את כל הfd-ים. זה O(1) לכל fd מוכן.

שלוש הפונקציות של epoll

#include <sys/epoll.h>

/* יצירת מופע epoll */
int epoll_fd = epoll_create1(0);

/* הוספת/שינוי/הסרת fd */
struct epoll_event event;
event.events = EPOLLIN;     /* מנטר לקריאה */
event.data.fd = sockfd;     /* שמירת הfd לזיהוי אחר כך */
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sockfd, &event);  /* הוספה */
epoll_ctl(epoll_fd, EPOLL_CTL_MOD, sockfd, &event);  /* שינוי */
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, sockfd, NULL);     /* הסרה */

/* המתנה לאירועים */
struct epoll_event events[MAX_EVENTS];
int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
    int ready_fd = events[i].data.fd;
    /* טיפול ב-ready_fd */
}

מצבי הפעלה - triggered

רמת סף - level-triggered (ברירת מחדל):
כל עוד יש נתונים בבאפר, epoll_wait יחזיר את הfd. אם קראנו רק חלק מהנתונים, הfd יופיע שוב בקריאה הבאה ל-epoll_wait. זה ההתנהגות הצפויה והבטוחה.

קצה - edge-triggered - EPOLLET:
epoll_wait מחזיר את הfd רק כשהמצב משתנה - כלומר, כשנתונים חדשים מגיעים. אם קראנו רק חלק מהנתונים, הfd לא יופיע שוב עד שנתונים חדשים יגיעו. זה מחייב אותנו לקרוא את כל הנתונים הזמינים בכל פעם (לולאת read עד EAGAIN).

Edge-triggered יעיל יותר (פחות קריאות ל-epoll_wait) אבל מסוכן יותר - אם שוכחים לקרוא הכל, נתונים יכולים להיתקע. נשתמש ב-level-triggered.

דוגמה מלאה - שרת הד עם epoll

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define PORT 8080
#define MAX_EVENTS 64
#define BUFFER_SIZE 1024

int main(void)
{
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd == -1) {
        perror("socket");
        exit(1);
    }

    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    server_addr.sin_addr.s_addr = INADDR_ANY;

    if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind");
        exit(1);
    }

    if (listen(server_fd, 10) == -1) {
        perror("listen");
        exit(1);
    }
    printf("epoll server listening on port %d...\n", PORT);

    /* יצירת מופע epoll */
    int epoll_fd = epoll_create1(0);
    if (epoll_fd == -1) {
        perror("epoll_create1");
        exit(1);
    }

    /* הוספת סוקט השרת ל-epoll */
    struct epoll_event event;
    event.events = EPOLLIN;
    event.data.fd = server_fd;
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &event) == -1) {
        perror("epoll_ctl");
        exit(1);
    }

    struct epoll_event events[MAX_EVENTS];

    while (1) {
        int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        if (n == -1) {
            perror("epoll_wait");
            break;
        }

        for (int i = 0; i < n; i++) {
            if (events[i].data.fd == server_fd) {
                /* לקוח חדש */
                struct sockaddr_in client_addr;
                socklen_t client_len = sizeof(client_addr);
                int client_fd = accept(server_fd,
                    (struct sockaddr *)&client_addr, &client_len);
                if (client_fd == -1) {
                    perror("accept");
                    continue;
                }

                char client_ip[INET_ADDRSTRLEN];
                inet_ntop(AF_INET, &client_addr.sin_addr,
                          client_ip, sizeof(client_ip));
                printf("new client: %s:%d (fd %d)\n",
                       client_ip, ntohs(client_addr.sin_port), client_fd);

                /* הוספת הלקוח ל-epoll */
                event.events = EPOLLIN;
                event.data.fd = client_fd;
                if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &event) == -1) {
                    perror("epoll_ctl add client");
                    close(client_fd);
                }

            } else {
                /* נתונים מלקוח קיים */
                int client_fd = events[i].data.fd;
                char buffer[BUFFER_SIZE];
                ssize_t bytes = read(client_fd, buffer, sizeof(buffer));

                if (bytes <= 0) {
                    printf("client fd %d disconnected\n", client_fd);
                    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL);
                    close(client_fd);
                } else {
                    write(client_fd, buffer, bytes);
                }
            }
        }
    }

    close(epoll_fd);
    close(server_fd);
    return 0;
}

טבלת השוואה

גישה מורכבות סקיילביליות בידוד שימוש טיפוסי
fork פשוט נמוכה מעולה שרתים קטנים, דורשי בידוד
תהליכונים - threads פשוט בינונית נמוכה שרתים בינוניים
select בינוני עד ~1024 fd - קוד ישן, ניידות
poll בינוני בינונית - ניידות, יותר מ-1024 fd
epoll מורכב מעולה - שרתים עתירי ביצועים

סוקטים לא חוסמים - non-blocking sockets

כברירת מחדל, קריאות כמו read, write, ו-accept חוסמות - כלומר, הן ממתינות עד שיש נתונים/מקום/חיבור. עם epoll, לפעמים רוצים סוקטים שלא חוסמים:

#include <fcntl.h>

int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

אחרי שהגדרנו סוקט כ-non-blocking:
- read שאין לו נתונים מחזיר -1 עם errno = EAGAIN (או EWOULDBLOCK) במקום לחסום
- write שהבאפר מלא מחזיר -1 עם errno = EAGAIN
- accept שאין חיבורים מחכים מחזיר -1 עם errno = EAGAIN

בשילוב עם epoll, הדפוס הוא:
1. כל הסוקטים non-blocking
2. epoll_wait מחכה עד שfd מוכן
3. קוראים/כותבים - מובטח שלא ייחסם (כי epoll אמר שהוא מוכן)
4. אם read מחזיר EAGAIN - אין יותר נתונים כרגע, חוזרים ל-epoll_wait


סיכום

  • שרת TCP צריך לטפל במספר לקוחות במקביל
  • fork: תהליך לכל לקוח - פשוט אבל יקר
  • תהליכונים - threads: קל יותר מfork, אבל יש בעיות concurrency
  • select: ניטור מרובה fd-ים בthread אחד, מוגבל ל-1024
  • poll: כמו select בלי מגבלת 1024
  • epoll: ספציפי ללינוקס, הכי יעיל, O(1) לfd מוכן - השרתים הגדולים בעולם משתמשים בזה
  • סוקטים non-blocking בשילוב עם epoll הם הדפוס המודרני לשרתים עתירי ביצועים