端口复用与惊群效应
端口复用与惊群效应
REUSEADDR
假设同一个机器上有2个套接字,分别bind到 ip1:port1、ip2:port2,如果 port1 == port2,则第二个bind的套接字会有"Address already in use"的错误。
为了允许多个套接字绑定到同一个port上,可以打开SO_REUSEADDR选项,如下例子
#include "stdio.h" #include "stdlib.h" #include <sys/types.h> /* See NOTES */ #include <sys/socket.h> #include <sys/types.h> #include <unistd.h> #include <fcntl.h> #include <arpa/inet.h> #include <string> #include <iostream> int bindSocket(char* ip, short port) { int nfd = socket(AF_INET, SOCK_STREAM, 0); if (nfd < 0) { perror("socket error "); return -1; } const int one = 1; setsockopt(nfd, SOL_SOCKET, SO_REUSEADDR, (char *)&one, sizeof(int)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = ip ? inet_addr(ip) : INADDR_ANY; addr.sin_port = htons(port); if (bind(nfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind error "); return -1; } listen(nfd, 1024);struct sockaddr_in addr2; socklen_t addrlen = 0; memset(&addr2, 0, sizeof(addr2)); if (accept(nfd, (struct sockaddr*)&addr2, &addrlen) < 0) { perror("accept error "); } return nfd; } int main() { int pid = fork(); if (pid == 0) { // child bindSocket("127.0.0.1", 7801); return 0; } else if (pid < 0) { perror("fork error"); } bindSocket("0.0.0.0", 7801); int res = 0; wait(&res); return 0; }
例子中,父进程bind到0.0.0.0:7801,子进程bind到127.0.0.1:7801,它们都可以bind成功。
注意,这里两个套接字bind的ip不一样(一个是127.0.0.1,一个是0.0.0.0),如果ip和port都一样,即使打开SO_REUSEADDR选项也会有冲突。
SO_REUSEADDR 还有一个作用,根据TCP协议,服务端如果主动关闭连接,会进入TIME_WAIT状态,在该状态下,如果又有套接字要bind到同一个IP:Port,也会有错误,但在开启SO_REUSEADDR时,就可以bind成功。
REUSEPORT
前面提到的reuseaddr 允许多个套接字绑定到同一个port(但ip不能相同),而reuseport允许将多个套接字bind在同一个IP+Port 对上。
那么当来了一个连接时,要由哪个套接字来处理它呢?reuseport分为两种模式:
- 热备份模式,在Linux 3.9内核引入,实际工作的套接字只有一个,其它的作为备份,只有当前一个套接字不再可用的时候,才会由后一个来取代,其投入工作的顺序取决于实现;
- 负载均衡模式,(在3.9内核之后),用数据包的源IP/源端口作为一个HASH函数的输入计算由哪个套接字来处理,所以同一个客户端的连接总是被分发到同一个套接字;
对于负载均衡模式,考虑是否存在这样的问题,对于UDP而言,比如一个事务中需要交互4个数据包,第1个数据包的元组HASH结果索引到了线程1的套接字,它理所当然被线程1处理,在第2个数据包到达之前,线程1挂了,那么该线程的套接字的位置将会被别的线程,比如线程2的套接字取代!在第2个数据包到达的时候,将会由线程2的套接字来处理之,然而线程2并不知道线程1保存的关于此连接的事务状态。
再讨论下reuseport的实现原理,对于 3.9 <= linux < 4.5 版本的内核,共享同一个port的套接字以链表的形式组织起来,如下图所示,
假设服务端建立了4个Server(A、B、C、D),监听的IP和port如图;
其中A和B使用了reuseport,比如说A有4个线程监听了0.0.0.0:21,而B有2个线程监听了192.168.10.1:21;
冲突链以port为key,因此A、B、D挂在同一条冲突链上;
如果此时客户端请求了192.168.10.1:21,那么内核会遍历listening_hash[0],为上面7个套接字打分,由于B监听的精准地址,所以得分会更高,内核会在sk_B0和sk_B1之间做选择。
从上面的例子可以看出,内核需要遍历冲突链,给监听该port上的所有socket打分,性能会有不足之处。
在 linux >= 4.5 版本的内核中进一步优化了这个问题,引入了reuseport group,它将bind到同一个ip:port的套接字进行分组,
这样当要查找目标地址为192.168.10.1:21的套接字时,可以直接在socketB reuseport group中查找,而不用再遍历整个冲突链。
注意,这个group特性在4.5版本中只支持了UDP协议,TCP要到4.6版本才支持。
再来看 reuseport 使用的一个例子,父、子进程都监听127.0.0.1:7801端口,
#include <string.h> #include "stdio.h" #include "stdlib.h" #include <sys/types.h> /* See NOTES */ #include <sys/socket.h> #include <sys/types.h> #include <sys/wait.h> #include <unistd.h> #include <fcntl.h> #include <arpa/inet.h> #include <string> #include <iostream> //using namespace std; int bindSocket(char* ip, short port) { int nfd = socket(AF_INET, SOCK_STREAM, 0); if (nfd < 0) { perror("socket error "); return -1; } const int one = 1; setsockopt(nfd, SOL_SOCKET, SO_REUSEPORT, (char *)&one, sizeof(int)); struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = ip ? inet_addr(ip) : INADDR_ANY; addr.sin_port = htons(port); if (bind(nfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind error "); return -1; } listen(nfd, 1024);struct sockaddr_in addr2; socklen_t addrlen = 0; while (true) { memset(&addr2, 0, sizeof(addr2)); int fd = accept(nfd, (struct sockaddr *) &addr2, &addrlen); if (fd < 0) { perror("accept error "); break; } write(fd, "hello\n", sizeof("hello") ); printf("pid=%d, receive request from %s:%d\n", getpid(), inet_ntoa(addr2.sin_addr), addr2.sin_port); close(fd); } return nfd; } int main() { int pid = fork(); if (pid == 0) { // child printf("child pid=%d\n", getpid()); bindSocket("127.0.0.1", 7801); return 0; } else if (pid < 0) { perror("fork error"); } // parent printf("parent pid=%d\n", getpid()); bindSocket("127.0.0.1", 7801); wait(0); return 0; }
我们用nc命令模拟发起请求,
nc 127.0.0.1 7801
多次执行如上命令的结果:
parent pid=16922 child pid=16923 pid=16922, receive request from 0.0.0.0:0 pid=16923, receive request from 0.0.0.0:0 pid=16922, receive request from 127.0.0.1:53343 pid=16923, receive request from 127.0.0.1:19552 pid=16922, receive request from 127.0.0.1:36960 pid=16922, receive request from 127.0.0.1:54880
可见,accept请求的监听套接字可能发生变化!这里是负载均衡模式。
惊群效应
上面说到的 reuseaddr、reuseport 都是不同套接字bind到同一个port上,套接字本身是不同的,每个套接字都有自己的accept队列。
但在有些场景下,是多个进程(一般是父子关系)或者多线程监听同一个套接字,因此这些父子进程(或多线程)共享同一个accept队列。
接下来我们以多进程为例说明,
当一个请求进来,accept同时唤醒等待socket的多个进程,但是只有一个进程能accept到新的socket,其他进程accept不到任何东西,只好继续回到accept流程,这就是惊群效应。
如果使用的是select/epoll + accept,则把惊群提前到了select/epoll这一步,多个进程只有一个进程能accept到连接,因为是非阻塞socket,其他进程返回EAGAIN。
accept 阻塞调用方式
看下面的例子,父进程创建套接字后先bind到127.0.0.1:7801,然后调用listen开始监听请求;
之后fork出5个子进程,每个子进程都会继承父进程的监听套接字,接着每个子进程去accept请求。
#include <string.h> #include "stdio.h" #include "stdlib.h" #include <sys/types.h> /* See NOTES */ #include <sys/socket.h> #include <sys/types.h> #include <sys/wait.h> #include <unistd.h> #include <fcntl.h> #include <arpa/inet.h> #include <string> #include <iostream> int bindSocket(char* ip, short port) { int nfd = socket(AF_INET, SOCK_STREAM, 0); if (nfd < 0) { perror("socket error "); return -1; } struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = ip ? inet_addr(ip) : INADDR_ANY; addr.sin_port = htons(port); if (bind(nfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind error "); return -1; } listen(nfd, 1024); return nfd; } void acceptSocket(int nfd) { struct sockaddr_in addr2; socklen_t addrlen = 0; while (true) { memset(&addr2, 0, sizeof(addr2)); int fd = accept(nfd, (struct sockaddr *) &addr2, &addrlen); if (fd < 0) { perror("accept error "); break; } write(fd, "hello\n", sizeof("hello") ); printf("pid=%d, receive request from %s:%d\n", getpid(), inet_ntoa(addr2.sin_addr), addr2.sin_port); close(fd); } } int main() { int nfd = bindSocket("127.0.0.1", 7801); for (int n = 0; n < 5; n++) { int pid = fork(); if (pid == 0) { // child printf("child pid=%d\n", getpid()); acceptSocket(nfd); return 0; } else if (pid < 0) { perror("fork error"); } } int res = 0; wait(&res); return 0; }
这时,通用用nc命令模拟请求:
nc 127.0.0.1 7801
运行结果
child pid=7478 child pid=7479 child pid=7480 child pid=7481 child pid=7482 pid=7478, receive request from 0.0.0.0:0
看起来只有一个子进程的accept调用返回了,难道惊群现象不存在吗?这时因为在Linux 2.6 版本以后,内核内核已经解决了accept()函数的“惊群”问题,大概的处理方式就是,当内核接收到一个客户连接后,只会唤醒等待队列上的第一个进程或线程。所以,如果服务器采用accept阻塞调用方式,在最新的Linux系统上,已经没有“惊群”的问题了。
epoll 方式
实际工程中常见的服务器程序,大都使用select、poll或epoll机制,此时,服务器不是阻塞在accept,而是阻塞在select、poll或epoll_wait,这种情况下的“惊群”仍然需要考虑。
看下面的例子,父进程创建套接字后先bind到127.0.0.1:7801,然后调用listen开始监听请求(这里会将监听套接字设置为非阻塞);
之后fork出5个子进程,每个子进程都会继承父进程的监听套接字,接着每个子进程创建一个epoll句柄,并将监听套接字的读事件注册到epoll中;
#include <string.h> #include "stdio.h" #include "stdlib.h" #include <sys/types.h> /* See NOTES */ #include <sys/socket.h> #include <sys/types.h> #include <sys/wait.h> #include <unistd.h> #include <fcntl.h> #include <arpa/inet.h> #include <string> #include <iostream> #include <sys/epoll.h> int bindSocket(char* ip, short port) { int nfd = socket(AF_INET, SOCK_STREAM, 0); if (nfd < 0) { perror("socket error "); return -1; } struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = ip ? inet_addr(ip) : INADDR_ANY; addr.sin_port = htons(port); if (bind(nfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind error "); return -1; } listen(nfd, 1024); int flags; if ((flags = fcntl(nfd, F_GETFL, 0)) < 0 || fcntl(nfd, F_SETFL, flags | O_NONBLOCK) < 0) { perror("fcntl error "); return -1; } return nfd; } void acceptSocket(int nfd) { struct sockaddr_in addr2; socklen_t addrlen = 0; memset(&addr2, 0, sizeof(addr2)); int fd = accept(nfd, (struct sockaddr *) &addr2, &addrlen); if (fd < 0) { perror("accept error "); return; } write(fd, "hello\n", sizeof("hello") ); printf("pid=%d, receive request from %s:%d\n", getpid(), inet_ntoa(addr2.sin_addr), addr2.sin_port); close(fd); } void pollSocket(int nfd) { const int MAX_EPOLL_EVENTS = 128; int epfd = epoll_create(MAX_EPOLL_EVENTS); if (epfd < 0) { perror("epoll_create error"); return; } struct epoll_event event; memset(&event, 0, sizeof(event)); event.events = EPOLLET | EPOLLIN; event.data.fd = nfd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, nfd, &event) == -1) { perror("epoll_ctl error"); return; } while (true) { struct epoll_event events[MAX_EPOLL_EVENTS]; memset(events, 0, sizeof(events)); int events_cnt = epoll_wait(epfd, events, MAX_EPOLL_EVENTS, 1000); if (events_cnt == 0) { //printf("epoll_wait timeout\n"); } else if (events_cnt < 0) { perror("epoll_wait error"); } else { for (int i = 0; i < events_cnt; i++) { if (events[i].events & EPOLLIN) { acceptSocket(events[i].data.fd); } else if (events[i].events & EPOLLERR) { printf("epoll_wait EPOLLERR\n"); } } } } } int main() { int nfd = bindSocket("127.0.0.1", 7801); for (int n = 0; n < 5; n++) { int pid = fork(); if (pid == 0) { // child printf("child pid=%d\n", getpid()); pollSocket(nfd); return 0; } else if (pid < 0) { perror("fork error"); } } int res = 0; wait(&res); return 0; }
这时,通用用nc命令模拟请求:
nc 127.0.0.1 7801
运行结果
child pid=25879 child pid=25880 child pid=25881 child pid=25882 child pid=25883 accept error : Resource temporarily unavailable accept error : Resource temporarily unavailable accept error : Resource temporarily unavailable pid=25881, receive request from 0.0.0.0:0 accept error : Resource temporarily unavailable
可见,当请求来临时,所有的子进程epoll_wait 均返回了一个可读事件,然后大家都去调用accept,但这个时候只有一个子进程能accept成功,其它子进程会报错,这就是惊群问题!
如何解决这个问题呢?Nginx中使用mutex互斥锁解决这个问题,具体措施有使用全局互斥锁,每个子进程在epoll_wait()之前先去申请锁,申请到则继续处理,获取不到则等待,并设置了一个负载均衡的算法(当某一个子进程的任务量达到总设置量的7/8时,则不会再尝试去申请锁)来均衡各个进程的任务量。
在上面的例子基础上,增加信号量对epoll_wait进行同步,代码如下
#include <string.h> #include "stdio.h" #include "stdlib.h" #include <sys/types.h> /* See NOTES */ #include <sys/socket.h> #include <sys/types.h> #include <sys/wait.h> #include <unistd.h> #include <fcntl.h> #include <arpa/inet.h> #include <string> #include <iostream> #include <sys/epoll.h> #include <sys/ipc.h> #include <sys/sem.h> union semun { int val; /* Value for SETVAL */ struct semid_ds *buf; /* Buffer for IPC_STAT, IPC_SET */ unsigned short *array; /* Array for GETALL, SETALL */ struct seminfo *__buf; /* Buffer for IPC_INFO (Linux-specific) */ }; int sem_init() { int semid = semget(IPC_PRIVATE, 1, 0666); if (semid == -1) { perror("semget error"); return -1; } union semun sem_union; sem_union.val = 1; if (semctl(semid, 0, SETVAL, sem_union) == -1) { perror("semctl error"); return -1; } return semid; } void sem_lock(int sem_id) { struct sembuf sem_b; sem_b.sem_num = 0; sem_b.sem_op = -1;//P() sem_b.sem_flg = SEM_UNDO; if (semop(sem_id, &sem_b, 1) == -1) { perror("sem_lock error"); } } void sem_unlock(int sem_id) { struct sembuf sem_b; sem_b.sem_num = 0; sem_b.sem_op = 1;//V() sem_b.sem_flg = SEM_UNDO; if (semop(sem_id, &sem_b, 1) == -1) { perror("sem_unlock error"); } } int bindSocket(char* ip, short port) { int nfd = socket(AF_INET, SOCK_STREAM, 0); if (nfd < 0) { perror("socket error "); return -1; } struct sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = ip ? inet_addr(ip) : INADDR_ANY; addr.sin_port = htons(port); if (bind(nfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind error "); return -1; } listen(nfd, 1024); int flags; if ((flags = fcntl(nfd, F_GETFL, 0)) < 0 || fcntl(nfd, F_SETFL, flags | O_NONBLOCK) < 0) { perror("fcntl error "); return -1; } return nfd; } void acceptSocket(int nfd) { struct sockaddr_in addr2; socklen_t addrlen = 0; memset(&addr2, 0, sizeof(addr2)); int fd = accept(nfd, (struct sockaddr *) &addr2, &addrlen); if (fd < 0) { perror("accept error "); return; } write(fd, "hello\n", sizeof("hello") ); printf("pid=%d, receive request from %s:%d\n", getpid(), inet_ntoa(addr2.sin_addr), addr2.sin_port); close(fd); } void pollSocket(int nfd, int semid) { const int MAX_EPOLL_EVENTS = 128; int epfd = epoll_create(MAX_EPOLL_EVENTS); if (epfd < 0) { perror("epoll_create error"); return; } struct epoll_event event; memset(&event, 0, sizeof(event)); event.events = EPOLLET | EPOLLIN; event.data.fd = nfd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, nfd, &event) == -1) { perror("epoll_ctl error"); return; } while (true) { struct epoll_event events[MAX_EPOLL_EVENTS]; memset(events, 0, sizeof(events)); sem_lock(semid); int events_cnt = epoll_wait(epfd, events, MAX_EPOLL_EVENTS, 1000); sem_unlock(semid); if (events_cnt == 0) { //printf("epoll_wait timeout\n"); } else if (events_cnt < 0) { perror("epoll_wait error"); } else { for (int i = 0; i < events_cnt; i++) { if (events[i].events & EPOLLIN) { acceptSocket(events[i].data.fd); } else if (events[i].events & EPOLLERR) { printf("epoll_wait EPOLLERR\n"); } } } } } int main() { int nfd = bindSocket("127.0.0.1", 7801); int semid = sem_init(); for (int n = 0; n < 5; n++) { int pid = fork(); if (pid == 0) { // child printf("child pid=%d\n", getpid()); pollSocket(nfd, semid); return 0; } else if (pid < 0) { perror("fork error"); } } int res = 0; wait(&res); return 0; }
除了加锁的解决方法外,还有其他2个办法:
- 利用reuseport机制(需要3.9以后版本),但这需要在每个子进程去创建监听端口(而不是继承父进程的),这样就可以保证每个子进程的套接字都是独立的,它们都有自己的accept队列,由内核来做负载均衡;
- liunx 4.5内核在epoll已经新增了EPOLL_EXCLUSIVE选项,在多个进程同时监听同一个socket,只有一个被唤醒。