浅谈C10K问题 与 解决方案

参考:

https://time.geekbang.org/column/article/143388

C10K问题

什么是C10K 问题

C10K 问题是这样的:如何在一台物理机上同时服务 10000 个用户?这里 C 表示并发,10K 等于 10000。得益于操作系统、编程语言的发展,在现在的条件下,普通用户使用框架或库就可以轻轻松松写出支持并发超过 10000 的服务器端程序,甚至于经过优化之后可以达到十万,乃至百万的并发,但在二十年前,突破 C10K 问题可费了不少的心思,是一个了不起的突破。

C10K的本质与考虑方面

C10K 问题本质上是一个操作系统问题,一台主机上同时支持 1 万个连接,需要考虑哪些方面?

文件句柄

每个客户连接都代表一个文件描述符,一旦文件描述符不够用了,新的连接就会被放弃并产生错误。在 Linux 下,单个进程打开的文件句柄数是有限制的,没有经过修改的值一般都是 1024。可以使用 root 权限修改 /etc/sysctl.conf 文件,使得系统可以支持 10000 个描述符上限。

系统内存

每个 TCP 连接占用的资源不简单的就是一个连接套接字,还需要占用一定的发送缓冲区和接收缓冲区。Linux 5.4.0 下发送缓冲区和接收缓冲区的值如下:

leacock@leacock-virtual-machine:~$ cat /proc/sys/net/ipv4/tcp_wmem
4096	16384	4194304
leacock@leacock-virtual-machine:~$ cat /proc/sys/net/ipv4/tcp_rmem
4096	131072	6291456
leacock@leacock-virtual-machine:~$ 

这三个值分别表示了最小分配值、默认分配值和最大分配值 。按照默认分配值计算,一万个连接需要的内存消耗为:

发送缓冲区: 16384*10000 = 160M bytes
接收缓冲区: 87380*10000 = 880M bytes

可见支持 1 万个并发连接,当下内存并不是一个巨大的瓶颈。

网络带宽

假设 1 万个连接,每个连接每秒传输大约 1KB 的数据,那么带宽需要 10000 x 1KB/s x8 = 80Mbps。在当下千兆万兆网卡之下也是小菜一碟。

在系统资源层面,C10K 问题是可以解决的。但是,能解决并不意味着可以很好地解决。在网络编程中,涉及到频繁的用户态 - 内核态数据拷贝,设计不够好的程序可能在低并发的情况下工作良好,一旦到了高并发情形,其性能可能呈现出指数级别的损失。

两个层面考虑

要想解决 C10K 问题,就需要从两个层面上来统筹考虑。

  • 第一个层面,应用程序如何和操作系统配合,感知 I/O 事件发生,并调度处理在上万个套接字上的 I/O 操作? 可参见IO模式与IO多路复用

  • 第二个层面,应用程序如何分配进程、线程资源来服务上万个连接?

解决方案:

两条思路方向

主要思路有两个:

  • 一个是对于每个连接处理分配一个独立的进程/线程;

  • 另一个思路是用同一进程/线程来同时处理若干连接。

几种解决方案

  • 阻塞 I/O + 进程

  • 阻塞 I/O + 线程

  • 非阻塞 I/O + readiness notification + 单线程

  • 非阻塞 I/O + readiness notification + 多线程

  • 异步 I/O+ 多线程

阻塞 I/O + 进程

最为简单直接最传统的方式,每个连接通过 fork 派生一个子进程进行处理,由于一个独立的子进程负责处理了该连接所有的 I/O,所以即便是阻塞 I/O,多个连接之间也不会互相影响。方法虽然简单,但是效率不高,扩展性差,资源占用率高。要处理好父子进程、僵尸进程等。

父进程和子进程

创建一个新的进程,使用函数 fork 就可以

pid_t fork(void)
返回:在子进程中为0,在父进程中为子进程ID,若出错则为-1

在调用该函数的进程(即为父进程)中返回的是新派生的进程 ID 号,在子进程中返回的值为 0。通过返回值可以区分父子进程然后进行相应的处理。

当一个子进程退出时,系统内核还保留了该进程的若干信息,比如退出状态。这样的进程如果不回收,就会变成僵尸进程。由父进程派生出来的子进程,也必须由父进程负责回收,否则子进程就会变成僵尸进程。

有两种方式可以在子进程退出后回收资源,分别是调用 wait 和 waitpid 函数。

pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);

wait 和 waitpid 函数参见 https://blog.csdn.net/csdn_kou/article/details/81091191

函数 wait 和 waitpid 都可以返回两个值,一个是函数返回值,表示已终止子进程的进程 ID 号,另一个则是通过 statloc 指针返回子进程终止的实际状态。这个状态可能的值为正常终止、被信号杀死、作业控制停止等。

处理子进程退出的方式一般是注册一个信号处理函数,捕捉信号 **SIGCHILD **信号,然后再在信号处理函数里调用 waitpid 函数来完成子进程资源的回收。SIGCHLD 是子进程退出或者中断时由内核向父进程发出的信号,默认这个信号是忽略的。

阻塞 I/O + 进程 代码示例

GitHub:BIOAndProgressDemo

fork

浅谈C10K问题 与 解决方案_第1张图片

服务端:


#define MAX_LINE 4096
#define SERV_PORT 5555

char convert_char(char c) {
    if ( 'A' <= c && c <= 'Z')
        return c + 32; // 转换小写
    else if ( 'a' <= c && c <= 'z')
        return c - 32; // 转换大写
    else
        return c; // 其他不变
}

void child_run(int fd) {

    printf("child_run int fd = %d\n",fd);

    char outbuf[MAX_LINE + 1];
    size_t outbuf_used = 0;
    ssize_t result;
    char ch[128];
    while (1) {
        bzero(outbuf,MAX_LINE + 1);
        bzero(ch,128);

        result = recv(fd, &ch, 128, 0);
        if (result == 0) {
            // 这里表示对端的socket已正常关闭.
            break;
        } else if (result == -1) {
            perror("read");
            break;
        }

        u_long len = strlen(ch);
        outbuf_used = 0;
        for (int i = 0; i < len; ++i) {
            outbuf[outbuf_used++] = convert_char(ch[i]);
        }
        send(fd, outbuf, outbuf_used, 0);

    }
    printf("child_run out\n");
}

/**
 * 信号处理函数
 * @param sig
 */
void sigchld_handler(int sig) {
    ///  pid =  -1 等待任何一个子进程退出,没有任何限制,此时waitpid和wait的作用一模一样
    /// WNOHANG 若由pid指定的子进程未发生状态改变(没有结束),则waitpid()不阻塞,立即返回0
    while (waitpid(-1, 0, WNOHANG) > 0);
    printf("sigchld_handler out\n");
}

/**
 * 创建服务端套 并 返回 监听套接字
 * @param port  监听端口
 * @return 监听套接字
 */
int tcp_server_listen(int port) {

    int listenfd;
    /// 监听套接字
    listenfd = socket(AF_INET, SOCK_STREAM, 0);

    /// 填写 sockaddr_in
    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(port);

    int on = 1;
    /// 设置属性
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

    /// 绑定ip
    int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
    if (rt1 < 0) {
        error(1, errno, "bind failed ");
    }

    /// 监听 套接字
    int rt2 = listen(listenfd, 1024);
    if (rt2 < 0) {
        error(1, errno, "listen failed ");
    }
    /// 捕获SIGPIPE信号  参见 https://blog.csdn.net/xinguan1267/article/details/17357093
    signal(SIGPIPE, SIG_IGN);

    return listenfd;
}
int main(int c, char **v) {

    /// 创建服务端
    int listener_fd = tcp_server_listen(SERV_PORT);

    /// 捕获 SIGCHLD 信号, 设置信号处理函数  sigchld_handler
    signal(SIGCHLD, sigchld_handler);
    /// 循环 监听 有连接到来 fork 进程处理
    while (1) {
        struct sockaddr_storage ss;
        socklen_t slen = sizeof(ss);
        /// accept
        int fd = accept(listener_fd, (struct sockaddr *) &ss, &slen);
        if (fd < 0) { /// accept 失败
            error(1, errno, "accept failed");
            exit(1);
        }

        if (fork() == 0) { /// fork 子进程 并通过返回值 区分 子父进程
            /// 子进程
            close(listener_fd); /// 关闭从父进程复制来的 listener_fd
            child_run(fd); /// 运行子程序
            exit(0);
        } else {
            /// 父进程
            close(fd);
        }
    }

    return 0;
}

客户端:


#define MAXLINE     4096
#define SERV_PORT 5555

int main() {

    int sockfd;
    struct sockaddr_in servaddr;
    // 创建了一个本地套接字
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        perror( "create socket failed");
    }

    // 初始化目标服务器端的地址, TCP 编程中,使用的是服务器的 IP 地址和端口作为目标
    bzero(&servaddr, sizeof(servaddr));

    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(SERV_PORT);
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);


    // 发起对目标套接字的 connect 调用
    if (connect(sockfd, (struct sockaddr *) &servaddr, sizeof(servaddr)) < 0) {
        perror("connect failed");
    }

    char send_line[MAXLINE];
    bzero(send_line, MAXLINE);
    char recv_line[MAXLINE];
    bzero(recv_line, MAXLINE);
    // 从标准输入中读取字符串,向服务器端发送
    while (1) {
        bzero(recv_line,MAXLINE); // 注意每次清空

        if (fgets(send_line, MAXLINE, stdin) == NULL)
            break;

        int nbytes = sizeof(send_line);
        if (send(sockfd, send_line, nbytes,0) != nbytes)
            perror("write error");

        bzero(recv_line, MAXLINE); // 注意每次清空
        if (recv(sockfd, recv_line, MAXLINE,0) == 0)
            perror("server terminated prematurely");

        fputs(recv_line, stdout);

    }

    exit(0);
}

测试:

浅谈C10K问题 与 解决方案_第2张图片

阻塞 I/O + 线程

使用进程模型来处理用户连接请求,进程切换上下文的代价是比较高的,有一种轻量级的模型可以处理多用户连接请求,就是线程模型。

线程由操作系统内核管理。每个线程都有自己的上下文(context),包括一个可以唯一标识线程的 ID(thread ID,或者叫 tid)、栈、程序计数器、寄存器等。在同一个进程中,所有的线程共享该进程的整个虚拟地址空间,包括代码、数据、堆、共享库等。

主要线程函数

创建线程


int pthread_create(pthread_t *tid, const pthread_attr_t *attr,
           void *(*func)(void *), void *arg);

返回:若成功则为0,若出错则为正的Exxx值
  • 第一个参数为指向线程标识符的指针。创建线程成功,tid 就返回正确的线程 ID

  • 第二个参数用来设置线程属性。如优先级、是否为守护进程等,如无特殊设置,可以直接指定这个参数为 NUL。

  • 第三个参数是线程运行函数的起始地址

  • 最后一个参数是运行函数的参数。如果我们想给线程入口函数传多个值,那么需要把这些值包装成一个结构体

在新线程的入口函数内,可以执行 pthread_self 函数返回线程 tid。

pthread_t pthread_self(void)

终止线程

终止一个线程最直接的方法是在父线程内调用函数:

void pthread_exit(void *status)

调用这个函数之后,父线程会等待其他所有的子线程终止,之后父线程自己终止。

但是绝大多数的子线程执行体都是一个无限循环。也可以通过调用 pthread_cancel 来主动终止一个子线程,和 pthread_exit 不同的是,它可以指定某个子线程终止。

int pthread_cancel(pthread_t tid)

回收已终止线程的资源

pthread_join 回收已终止线程的资源


int pthread_join(pthread_t tid, void ** thread_return)

当调用 pthread_join 时,主线程会阻塞,直到对应 tid 的子线程自然终止。和 pthread_cancel 不同的是,它不会强迫子线程终止。

分离线程

一个线程的重要属性是可结合的,或者是分离的。一个可结合的线程是能够被其他线程杀死和回收资源的;而一个分离的线程不能被其他线程杀死或回收资源。一般来说,默认的属性是可结合的。

pthread_detach 函数可以分离一个线程:

int pthread_detach(pthread_t tid)

阻塞 I/O + 线程 代码示例

GitHub:BIOAndThreadDemo

对上面服务端稍作修改,客户端不变

pthread

浅谈C10K问题 与 解决方案_第3张图片

服务端:

#include 
#include 
#include 
#include 
#include 
#include 
#include  
#include 
#include 
#include 

#define MAX_LINE 4096
#define SERV_PORT 5555

char convert_char(char c) {
    if ( 'A' <= c && c <= 'Z')
        return c + 32; // 转换小写
    else if ( 'a' <= c && c <= 'z')
        return c - 32; // 转换大写
    else
        return c; // 其他不变
}

void thread_run(void *arg) {

    pthread_detach(pthread_self());

    int fd = (int)arg;

    printf("thread_run int fd = %d\n",fd);


    char outbuf[MAX_LINE + 1];
    size_t outbuf_used = 0;
    ssize_t result;
    char ch[128];
    while (1) {
        bzero(outbuf,MAX_LINE + 1);
        bzero(ch,128);

        result = recv(fd, &ch, 128, 0);
        if (result == 0) {
            // 这里表示对端的socket已正常关闭.
            break;
        } else if (result == -1) {
            perror("read");
            break;
        }

        u_long len = strlen(ch);
        outbuf_used = 0;
        for (int i = 0; i < len; ++i) {
            outbuf[outbuf_used++] = convert_char(ch[i]);
        }
        send(fd, outbuf, outbuf_used, 0);

    }
    printf("thread_run out\n");
}


/**
 * 创建服务端套 并 返回 监听套接字
 * @param port  监听端口
 * @return 监听套接字
 */
int tcp_server_listen(int port) {

    int listenfd;
    /// 监听套接字
    listenfd = socket(AF_INET, SOCK_STREAM, 0);

    /// 填写 sockaddr_in
    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(port);

    int on = 1;
    /// 设置属性
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

    /// 绑定ip
    int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
    if (rt1 < 0) {
        error(1, errno, "bind failed ");
    }

    /// 监听 套接字
    int rt2 = listen(listenfd, 1024);
    if (rt2 < 0) {
        error(1, errno, "listen failed ");
    }
    /// 捕获SIGPIPE信号  参见 https://blog.csdn.net/xinguan1267/article/details/17357093
    signal(SIGPIPE, SIG_IGN);

    return listenfd;
}
int main(int c, char **v) {
    /// 创建服务端
    int listener_fd = tcp_server_listen(SERV_PORT);

    pthread_t tid;
    /// 循环 监听 有连接到来 fork 进程处理
    while (1) {
        struct sockaddr_storage ss;
        socklen_t slen = sizeof(ss);
        /// accept
        int fd = accept(listener_fd, (struct sockaddr *) &ss, &slen);
        if (fd < 0) { /// accept 失败
            error(1, errno, "accept failed");

        } else {
            pthread_create(&tid, NULL, &thread_run, (void *) fd);
        }
    }

    return 0;
}

测试:

浅谈C10K问题 与 解决方案_第4张图片

非阻塞 I/O + readiness notification + 单线程(reactor)

事件驱动模型

事件驱动模型,也被叫做反应堆模型(reactor),或者是 Event loop 模型。这个模型的核心有两点。

  • 第一,它存在一个无限循环的事件分发线程,或者叫做 reactor 线程、Event loop 线程。这个事件分发线程的背后,就是 poll、epoll 等 I/O 分发技术的使用。
  • 第二,所有的 I/O 操作都可以抽象成事件,每个事件必须有回调函数来处理。通过事件分发,这些事件都可以一一被检测,并调用对应的回调函数加以处理。

single reactor thread

一个 reactor 线程上同时负责分发 acceptor 的事件、已连接套接字的 I/O 事件。

浅谈C10K问题 与 解决方案_第5张图片

single reactor thread + worker threads

进一步优化将耗时的操作分离出来,反应堆线程只负责处理 I/O 相关的工作,业务逻辑相关的工作都被裁剪成一个一个的小任务,放到线程池里由空闲的线程来执行。当结果完成后,再交给反应堆线程,由反应堆线程通过套接字将结果发送出去。

浅谈C10K问题 与 解决方案_第6张图片

非阻塞 I/O + readiness notification + 单线程(reactor)代码示例

参见:

Reactor模式与单线程Reactor的C和C++实现

非阻塞 I/O + readiness notification + 多线程(主 - 从 reactor)

主 - 从 reactor 模式

单 reactor 线程既分发连接建立,又分发已建立连接的 I/O;将 acceptor 上的连接建立事件和已建立连接的 I/O 事件分离,形成所谓的主 - 从 reactor 模式。

主 - 从这个模式的核心思想是,主反应堆线程只负责分发 Acceptor 连接建立,已连接套接字上的 I/O 事件交给 sub-reactor 负责分发。其中 sub-reactor 的数量,可以根据 CPU 的核数来灵活设置。
浅谈C10K问题 与 解决方案_第7张图片

主反应堆线程一直在感知连接建立的事件,如果有连接成功建立,主反应堆线程通过 accept 方法获取已连接套接字,接下来会按照一定的算法选取一个从反应堆线程,并把已连接套接字加入到选择好的从反应堆线程中。主反应堆线程唯一的工作,就是调用 accept 获取已连接套接字,以及将已连接套接字加入到从反应堆线程中。

主 - 从 reactor+worker threads 模式

主 - 从 reactor 模式解决了 I/O 分发的高效率问题,那么 work threads 就解决了业务逻辑和 I/O 分发之间的耦合问题。

主 - 从反应堆下加上 worker 线程池。

主 - 从反应堆跟上面介绍的做法是一样的。和上面不一样的是,这里将 decode、compute、encode 等 CPU 密集型的工作从 I/O 线程中拿走,这些工作交给 worker 线程池来处理,而且这些工作拆分成了一个个子任务进行。encode 之后完成的结果再由 sub-reactor 的 I/O 线程发送出去。

非阻塞 I/O + readiness notification + 多线程(主 - 从 reactor) 代码示例

未实现

异步 I/O+ 多线程

待整理

可以看下 AIO 的新归宿:io_uring

你可能感兴趣的:(库学习与功能demo,网络编程实战,Linux相关,c10k,fork,thread,reactor,threadpool)