• 端口复用与惊群效应


    端口复用与惊群效应

    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
    ", sizeof("hello") );
    
            printf("pid=%d, receive request from %s:%d
    ",
                   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
    ", getpid());
            bindSocket("127.0.0.1", 7801);
            return 0;
    
        } else if (pid < 0) {
            perror("fork error");
        }
    
        // parent
        printf("parent pid=%d
    ", 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
    ", sizeof("hello") );
    
            printf("pid=%d, receive request from %s:%d
    ",
                   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
    ", 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
    ", sizeof("hello") );
    
        printf("pid=%d, receive request from %s:%d
    ",
                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
    ");
    
            } 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
    ");
                    }
                }
            }
        }
    }
    
    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
    ", 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
    ", sizeof("hello") );
    
        printf("pid=%d, receive request from %s:%d
    ",
                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
    ");
    
            } 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
    ");
                    }
                }
            }
    
        }
    }
    
    
    
    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
    ", 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,只有一个被唤醒。
  • 相关阅读:
    Makefile 跟着走快点
    MariaDB 复合语句和优化套路
    Unity Shader常用函数,标签,指令,宏总结(持续更新)
    ThreadLocal 简述
    Java全排列排序
    Thrift入门
    Nginx + Keepalived 双机热备
    Linux 虚拟IP
    Java 反编译
    Spring拦截器
  • 原文地址:https://www.cnblogs.com/chenny7/p/14243270.html
Copyright © 2020-2023  润新知