שרת TCP מתקדם - advanced TCP server¶
הבעיה¶
בפרק 11.1 כתבנו שרת הד שעובד, אבל הוא מטפל בלקוח אחד בכל פעם. כל עוד השרת עסוק בלולאת read/write עם לקוח אחד, לקוחות אחרים שמנסים להתחבר חייבים לחכות. בעולם האמיתי, שרת צריך לטפל במאות או אלפי לקוחות במקביל.
בהרצאה הזו נלמד חמש גישות שונות לפתור את הבעיה, מהפשוטה ביותר ועד המתקדמת ביותר.
גישה 1: הprocess לכל חיבור - fork per connection¶
הרעיון: אחרי accept, השרת עושה fork. הprocess הילד מטפל בלקוח, וprocess האב חוזר ל-accept לקבל לקוחות חדשים.
למדנו על fork בפרק 5.2. כל process ילד מקבל עותק של כל ה-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); /* the child process finishes */
}
int main(void)
{
/* handle SIGCHLD - automatic cleanup of child processes */
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) {
/* child process - handles the client */
close(server_fd); /* the child doesn't need the server socket */
handle_client(client_fd, &client_addr);
/* we never get here - handle_client calls exit */
}
/* parent process - goes back to accept */
close(client_fd); /* the parent doesn't need the client socket */
}
return 0;
}
יתרונות:
- פשוט ליישום
- כל לקוח מבודד בprocess נפרד - קריסה של לקוח אחד לא משפיעה על אחרים
- טוב לשרתים עם מעט לקוחות שכל אחד דורש הרבה עיבוד
חסרונות:
- fork יקר - יצירת process חדש דורשת משאבים (זיכרון, זמן)
- לא סקיילבילי - עם 10,000 לקוחות נצטרך 10,000 processes
- תקשורת בין הלקוחות מסובכת (צריך IPC)
גישה 2: הthread לכל חיבור - thread per connection¶
הרעיון דומה לfork, אבל במקום process חדש יוצרים thread. למדנו על threads בפרק 5.8. הם קלים יותר מprocesses כי חולקים את אותו מרחב זיכרון.
#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); /* no need to join - the thread cleans up after itself */
}
return 0;
}
קומפילציה (צריך להוסיף -pthread):
שימו לב: מקצים struct client_info על הheap (עם malloc) ולא על הstack. למה? כי אם נגדיר אותו כמשתנה מקומי בלולאה, הלולאה תדרוס אותו בסיבוב הבא - לפני שהthread הספיק לקרוא אותו. זה race condition קלאסי.
יתרונות:
- קל יותר מfork - threads זולים יותר מprocesses
- תקשורת בין threads קלה (חולקים זיכרון)
חסרונות:
- עדיין thread אחד לכל לקוח - לא סקיילבילי ל-10,000 לקוחות
- באגים של concurrency (race conditions, deadlocks)
- קריסה של thread אחד יכולה להפיל את כל השרת
גישה 3: ריבוב קלט/פלט עם select - I/O multiplexing¶
מה אם במקום ליצור thread או process לכל לקוח, נשתמש ב-thread אחד שמנטר הרבה סוקטים בו-זמנית? זה הרעיון של I/O multiplexing.
select מאפשר לנו לחכות עד שאחד (או יותר) מתוך רשימת fd-ים מוכן לקריאה או כתיבה. במקום read שחוסם אותנו על fd אחד, select חוסם אותנו עד שאיזשהו fd מוכן.
איך זה עובד¶
- יוצרים קבוצה (
fd_set) של fd-ים שרוצים לנטר - קוראים ל-
select- הוא חוסם עד שלפחות fd אחד מוכן - בודקים אילו fd-ים מוכנים ומטפלים בהם
- חוזרים לצעד 1
המקרואים של fd_set¶
fd_set read_fds;
FD_ZERO(&read_fds); // zero the set
FD_SET(fd, &read_fds); // add fd to the set
FD_CLR(fd, &read_fds); // remove fd from the set
FD_ISSET(fd, &read_fds); // check if fd is in the set (returns != 0 if so)
הפונקציה 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);
/* array of client fds */
int clients[MAX_CLIENTS];
for (int i = 0; i < MAX_CLIENTS; i++)
clients[i] = -1; /* -1 = empty */
int max_fd = server_fd;
while (1) {
/* rebuild the fd set every round */
fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(server_fd, &read_fds); /* always monitor the server socket */
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];
}
}
/* wait until some fd is ready */
int ready = select(max_fd + 1, &read_fds, NULL, NULL, NULL);
if (ready == -1) {
perror("select");
break;
}
/* check: is the server socket ready? (= a new client is waiting) */
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);
/* add the client to the array */
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);
}
}
}
/* check: did any client send data? */
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) {
/* client disconnected */
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/processes
- אין בעיות 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; /* events we're waiting for */
short revents; /* events that occurred (filled in by poll) */
};
האירועים הנפוצים:
- POLLIN - יש נתונים לקריאה
- POLLOUT - אפשר לכתוב
- POLLERR - שגיאה
- POLLHUP - הצד השני סגר את החיבור
דוגמה קצרה¶
struct pollfd fds[MAX_CLIENTS + 1];
int nfds = 0;
/* the server socket */
fds[0].fd = server_fd;
fds[0].events = POLLIN;
nfds = 1;
while (1) {
int ready = poll(fds, nfds, -1); /* -1 = wait forever */
if (ready == -1) {
perror("poll");
break;
}
/* check the server socket */
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++;
}
}
/* check clients */
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);
/* move the last one into the current slot */
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>
/* create an epoll instance */
int epoll_fd = epoll_create1(0);
/* add/modify/remove fd */
struct epoll_event event;
event.events = EPOLLIN; /* monitor for read */
event.data.fd = sockfd; /* store the fd for identification later */
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sockfd, &event); /* add */
epoll_ctl(epoll_fd, EPOLL_CTL_MOD, sockfd, &event); /* modify */
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, sockfd, NULL); /* remove */
/* wait for events */
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;
/* handle 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);
/* create an epoll instance */
int epoll_fd = epoll_create1(0);
if (epoll_fd == -1) {
perror("epoll_create1");
exit(1);
}
/* add the server socket to 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) {
/* new client */
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);
/* add the client to 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 {
/* data from an existing client */
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: הprocess לכל לקוח - פשוט אבל יקר
- threads: קל יותר מfork, אבל יש בעיות concurrency
- select: ניטור מרובה fd-ים בthread אחד, מוגבל ל-1024
- poll: כמו select בלי מגבלת 1024
- epoll: ספציפי ללינוקס, הכי יעיל, O(1) לfd מוכן - השרתים הגדולים בעולם משתמשים בזה
- סוקטים non-blocking בשילוב עם epoll הם הדפוס המודרני לשרתים עתירי ביצועים