端口复用与惊群效应

端口复用与惊群效应

 

 

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;
}
View Code

 

除了加锁的解决方法外,还有其他2个办法:

  1. 利用reuseport机制(需要3.9以后版本),但这需要在每个子进程去创建监听端口(而不是继承父进程的),这样就可以保证每个子进程的套接字都是独立的,它们都有自己的accept队列,由内核来做负载均衡;
  2. liunx 4.5内核在epoll已经新增了EPOLL_EXCLUSIVE选项,在多个进程同时监听同一个socket,只有一个被唤醒。

 

posted @ 2021-01-07 19:45  如果的事  阅读(1008)  评论(0编辑  收藏  举报