C 语言网络编程 — 高并发 TCP 网络服务器

Posted 范桂飓

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C 语言网络编程 — 高并发 TCP 网络服务器相关的知识,希望对你有一定的参考价值。

目录

文章目录

TCP Socket 编程示例


服务端

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>

#include <arpa/inet.h>
#include <sys/socket.h>


#define ERR_MSG(err_code) do                                      \\
    err_code = errno;                                              \\
    fprintf(stderr, "ERROR code: %d \\n", err_code);                \\
    perror("PERROR message");                                      \\
 while (0)

const int BUF_LEN = 100;


int main(void)

    /* 配置 Server Sock 信息。*/
    struct sockaddr_in srv_sock_addr;
    memset(&srv_sock_addr, 0, sizeof(srv_sock_addr));
    srv_sock_addr.sin_family = AF_INET;
    srv_sock_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // 即 0.0.0.0 表示监听本机所有的 IP 地址。
    srv_sock_addr.sin_port = htons(6666);

    /* 创建 Server Socket。*/
    int srv_socket_fd = 0;
    if (-1 == (srv_socket_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP))) 
        printf("Create socket file descriptor ERROR.\\n");
        ERR_MSG(errno);
        exit(EXIT_FAILURE);
    
    /* 设置 Server Socket 选项。*/
    int optval = 1;
    if (setsockopt(srv_socket_fd,
                   SOL_SOCKET,    // 表示套接字选项的协议层。
                   SO_REUSEADDR,  // 表示在绑定地址时允许重用本地地址。这样做的好处是,当服务器进程崩溃或被关闭时,可以更快地重新启动服务器,而不必等待一段时间来释放之前使用的套接字。
                   &optval,
                   sizeof(optval)) < 0)
    
        printf("Set socket options ERROR.\\n");
        ERR_MSG(errno);
        exit(EXIT_FAILURE);
    

    /* 绑定 Socket 与 Sock Address 信息。*/
    if (-1 == bind(srv_socket_fd,
                   (struct sockaddr *)&srv_sock_addr,
                   sizeof(srv_sock_addr)))
    
        printf("Bind socket ERROR.\\n");
        ERR_MSG(errno);
        exit(EXIT_FAILURE);
    

    /* 开始监听 Client 发出的连接请求。*/
    if (-1 == listen(srv_socket_fd, 10))
    
        printf("Listen socket ERROR.\\n");
        ERR_MSG(errno);
        exit(EXIT_FAILURE);
    

    /* 初始化 Client Sock 信息存储变量。*/
    struct sockaddr cli_sock_addr;
    memset(&cli_sock_addr, 0, sizeof(cli_sock_addr));
    int cli_sockaddr_len = sizeof(cli_sock_addr);

    int cli_socket_fd = 0;

    int recv_len = 0;
    char buff[BUF_LEN] = 0;

    /* 永远接受 Client 的连接请求。*/
    while (1)
    
        if (-1 == (cli_socket_fd = accept(srv_socket_fd,
                                          (struct sockaddr *)(&cli_sock_addr),  // 填充 Client Sock 信息。
                                          (socklen_t *)&cli_sockaddr_len)))
        
            printf("Accept connection from client ERROR.\\n");
            ERR_MSG(errno);
            exit(EXIT_FAILURE);
        

        /* 接收指定 Client Socket 发出的数据,*/
        if ((recv_len = recv(cli_socket_fd, buff, BUF_LEN, 0)) < 0)
        
            printf("Receive from client ERROR.\\n");
            ERR_MSG(errno);
            exit(EXIT_FAILURE);
        
        printf("Recevice data from client: %s\\n", buff);

        /* 将收到的数据重新发送给指定的 Client Socket。*/
        send(cli_socket_fd, buff, recv_len, 0);
        printf("Send data to client: %s\\n", buff);

        /* 每处理完一次 Client 请求,即关闭连接。*/
        close(cli_socket_fd);
        memset(buff, 0, BUF_LEN);
    

    close(srv_socket_fd);
    return EXIT_SUCCESS;

客户端

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>

#include <arpa/inet.h>
#include <sys/socket.h>


#define ERR_MSG(err_code) do                                      \\
    err_code = errno;                                              \\
    fprintf(stderr, "ERROR code: %d \\n", err_code);                \\
    perror("PERROR message");                                      \\
 while (0)

const int BUF_LEN = 100;


int main(void)

    /* 配置 Server Sock 信息。*/
    struct sockaddr_in srv_sock_addr;
    memset(&srv_sock_addr, 0, sizeof(srv_sock_addr));
    srv_sock_addr.sin_family = AF_INET;
    srv_sock_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    srv_sock_addr.sin_port = htons(6666);

    int cli_socket_fd = 0;
    char send_buff[BUF_LEN];
    char recv_buff[BUF_LEN];

    /* 永循环从终端接收输入,并发送到 Server。*/
    while (1) 

        /* 创建 Client Socket。*/
        if (-1 == (cli_socket_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)))
        
            printf("Create socket ERROR.\\n");
            ERR_MSG(errno);
            exit(EXIT_FAILURE);
        

        /* 连接到 Server Sock 信息指定的 Server。*/
        if (-1 == connect(cli_socket_fd,
                          (struct sockaddr *)&srv_sock_addr,
                          sizeof(srv_sock_addr)))
        
            printf("Connect to server ERROR.\\n");
            ERR_MSG(errno);
            exit(EXIT_FAILURE);
        

        /* 从 stdin 接收输入,再发送到建立连接的 Server Socket。*/
        fputs("Send to server> ", stdout);
        fgets(send_buff, BUF_LEN, stdin);
        send(cli_socket_fd, send_buff, BUF_LEN, 0);
        memset(send_buff, 0, BUF_LEN);

        /* 从建立连接的 Server 接收数据。*/
        recv(cli_socket_fd, recv_buff, BUF_LEN, 0);
        printf("Recevice from server: %s\\n", recv_buff);
        memset(recv_buff, 0, BUF_LEN);

        /* 每次 Client 请求和响应完成后,关闭连接。*/
        close(cli_socket_fd);
    

    return EXIT_SUCCESS;

测试

编译:

$ gcc -g -std=c99 -Wall tcp_server.c -o tcp_server
$ gcc -g -std=c99 -Wall tcp_client.c -o tcp_client

运行:

  1. 先启动 TCP Server:
$ ./tcp_server
  1. 查看监听 Socket 是否绑定成功:
$ netstat -lpntu | grep 6666
tcp        0      0 0.0.0.0:6666            0.0.0.0:*               LISTEN      28675/./tcp_server
  1. 启动 TCP Client
$ ./tcp_client

高并发 TCP 网络服务器

I/O 并发模型设计

  1. 多进程模型:主进程负责 Listen 和 Accept 连接请求;Accept 后,就 fock 子进程来处理 read() 和 write()。缺点是,多进程数量有限,消耗资源也多。

  2. 多线程模型:使用线程来处理 read() 和 write() 会更加高效。但无论是使用多进程还是多线程,如果一个 TCP 连接只对应了一个进程或线程,就很难逃脱 C10K 的问题。

  3. I/O 多路复用模型:例如 epoll(),可以使得一个进程或线程能够处理多个 TCP 连接。

以典型的 I/O 多路复用模型 nginx 为例,实现了 Master + Worker 软件架构。Worker 的数量通常等于 CPU Cores 的数量,并且每个 Worker 都采用了 epoll 模型。所有 Worker 都在 80/443 端口上 Listen 连接请求,并且把监听到的 client socket fds 添加在各自的 epoll 中,然后在 Events 发生时回调。

可见,I/O 多路复用模型大大增加了每个进程可以管理的 Socket 数量,直到操作系统 fd 最大数量限制为止,通常可以设置百万级别,即:单机单进程支持百万连接,epoll 是解决 C10K 的利器,很多开源软件用到了它。

系统文件描述符数量限制

Linux 中一切皆文件,每个 Socket 都有各自的文件描述符,作为系统操作这个 Socket 的文件句柄。Linux 中的每个 User Process 都有一个 fd 数组,保存了自己拥有的所有 fds。

为了系统的安全性,Linux 为预设 socket fd 的 Limit(数量限制),若果压力超过了则会出现 too many open files 系统异常。

修改系统设置:

$ vim /etc/security/limits.conf
...
* soft nofile 1000000
* hard nofile 1000000
root soft nofile 1000000
root hard nofile 1000000

完全断开连接导致的性能问题

四次挥手是一个冗长的过程,由于网络环境的复杂性与 TCP 连接的可靠性相违背,所以在某些特殊的场景中会出现相应的问题。比较常见的就是在高并发网络服务器场景中,由于 “完全断开连接“ 导致的性能问题。

TCP 协议规定,C/S 双方必须完整进行四次挥手,进入到 CLOSED 状态,各自的 Kernel 才会完全释放 Socket 资源。如果由于网络连通性或其他原因导致四次挥手没有完成,那么这个 Socket 的连接就会处于假死状态,并且继续占用系统资源。

具体而言,有以下几种情况:

  1. TCP 协议规定 TIME_WAIT 状态会持续 240s(2MSL),以此来保证后面新建的连接不会受到旧连接残留的延迟重发报文的影响。所以,高并发的网络服务器通常不应该主动 close(),而是让对方主动,避免出现大量的 TIME_WAIT 连接占用系统资源。

  2. TCP 协议规定 FIN_WAIT_2 状态有 60s(默认)超时等待时间,如果对方一直不 close(),那么 FIN_WAIT_2 也会一致占用系统资源。

  3. TCP 协议规定 CLOSE_WAIT 状态有 2h(默认)超时等待时间,如果由于某些原因,使得自己一直不 close(),那么系统负载在 2h 内可能会积累到崩溃的程度。

关注 TCP 连接的状态

所以,实际上,close() 并不会马上断开 Socket Connection,在高性能网络服务器中,需要非常关注 TCP 连接的状态情况。

查看 Linux 上的 TCP 连接的状态:

$ netstat -n | awk '/^tcp/ ++S[$NF] END for(a in S) print a, S[a]'                                                                                                                                                                                                  127 ↵
CLOSE_WAIT 1
TIME_WAIT 1
ESTABLISHED 17

合理配置 TCP 连接内核参数

# vi /etc/sysctl.conf

# 表示开启重用。允许将 TIME-WAIT Sockets 重新用于新的 TCP 连接,默认为 0,表示关闭。
net.ipv4.tcp_tw_reuse = 1

# 表示开启 TCP 连接中 TIME-WAIT Sockets 的快速回收,默认为 0,表示关闭。
net.ipv4.tcp_tw_recycle = 1

# 表示系統等待 FIN_WAIT 超时时间。
net.ipv4.tcp_fin_timeout

使用 shutdown() 来确保 Connection 被正常关闭

推荐在 close 之前调用 shutdown 函数来确保连接会被正常关闭。

而且 shutdown 函数也提供了多种不同的关闭方式:

  • SHUT_RD:关闭读,不能使用 read / recv。常用在服务端程序,立即关闭读取客户端的请求,但仍会完成对之前请求的响应。
  • SHUT_WR:关闭写,不能使用 write / send。常用在客户端程序,立即关闭写操作,但仍可以继续将响应数据读完。
  • SHUT_RDWR:关闭读写,不能使用 read / recv / write / send。常用在对精度要求不高的场景。

断开重连问题

Socket API 没有原生的自动重连机制,需要 Application 自身实现网络断开重连功能。在执行 Send 和 Receive 之前,检查 Connection 是否 ACTIVE。

Connection 状态检测通常是 Server 需要关注的特性,Server 以此来决定回收 Socket 资源,或者执行断开重连。而 Client 只需要重新连接、重新发送即可。但问题是,初始情况下,Server 无法有效的区分 Client 目前是处于 “长期空闲” 还是 “下线“ 状态。解决这个问题的思路就是通过建立 Heartbeat(心跳)协议,让 Client 始终忙碌,以此来排除掉 Client “长期空闲“ 的情况。

解决这个问题常见的思路有 2 种:

  1. Server 使用 Keepalive 特性来判断 Connection 是否 ACTIVE。
  2. Client 实现 Heartbeat 特性来减轻 Server 的压力。

使用 Keepalive 特性来判断 Connection 是否 ACTIVE

Keepalive 是 TCP 连接的一种特性,若 TCP Socket 开启了 SO_KEEPALIVE,那么 Kernel 会为每次 TCP 连接设施一个 Timer。如果在一段时间内没有 Read / Write 交换,则会向对端发送一个 ACK 探测消息,以确保连接仍然存在。如果对方没有响应,则可以认为连接已断开,并关闭连接。

Keepalive 也会增加网络开销,应该根据具体的应用场景和网络环境来决定是否使用。

Socket API 要启用 keepalive,需要使用 setsockopt() 函数来设置相应的选项。

#include <<netinet/in.h>>
int optval = 1;
setsockopt(sockfd, SOL_SOCKET, SO_KEEPALIVE, &optval, sizeof(optval));

# include <<netinet/tcp.h>>
int idle = 60;
int interval = 10;
int count = 3;
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle));
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval));
setsockopt(sockfd, IPPROTO_TCP, TCP_KEEPCNT, &count, sizeof(count));
  • SO_KEEPALIVE:启用 Keepalive 特性。
  • TCP_KEEPIDLE:指定在没有数据交换时,保持连接活动的时间。
  • TCP_KEEPINTVL:指定发送 Keepalive 探测消息的时间间隔。
  • TCP_KEEPCNT:指定发送 Keepalive 探测消息的次数。

需要注意的是,在 HTTP/1.x 协议中也有 Keep-Alive 的概念。通过在 HTTP Header 中设置 connection: Keep-Alive 字段来告知对方自己支持并期望使用长连接通信,这和 TCP keepalive 保活探测的作用是完全不同的。

另外, gRPC 使用 HTTP/2.0 作为传输协议, 从该协议的设计来讲, 长连接也是更推荐的使用方式, 原因如下:

  1. HTTP/2.0 的多路复用,使得连接的复用效率得到了质的提升。
  2. HTTP/2.0 的单个连接维持的成本更高。

Kernel Socket Buffer 与 TCP 滑动窗口问题

滑动窗口是实现 TCP 网络拥塞控制和流量控制的基础,Sender 和 Receiver 互相 “协商” 彼此的 swnd(发送窗口)和 rwnd(接收窗口)。而 swnd 和 rwnd 的本质都是 Kernel Socket Buffer 中为 TCP Application 分配的一块空间。

当 Linux 系统资源紧张时,或当 TCP Application 从 Buffer 中拉取数据消极时,都可能会导致滑动窗口的效率降低。甚至,会出现数据截断导致的数据完整性缺失。

  • TCP Application 不及时处理数据导致的窗口关闭

  • 系统资源紧张导致 Sender 数据截断,而部分数据滞留 Receiver,并最终丢弃。

常规 TCP 内核参数调优

$ vim /etc/sysctl.conf

### 系统允许的最大文件句柄数
fs.file-max = 12553500

### 单个进程允许的最大文件句柄数
fs.nr_open = 12453500

### 内核允许使用的共享内存段
# Controls the maximum number of shared memory segments, in pages
kernel.shmall = 4294967296

### 单个共享内存段的最大值
# Controls the maximum shared segment size, in bytes
kernel.shmmax = 68719476736

### 内核消息队列中消息的最大值
# Controls the maximum size of a message, in bytes
kernel.msgmax = 65536

### 关闭系统救援工具 Sysrq
kernel.sysrq = 0

### 限制内核最大进程数量
kernel.pid_max = 65536

### 当网络接口接收数据包的速率快于内核处理这些数据包的速率时,允许数据包被送到缓存队列的最大包数。
net.core.netdev_max_backlog = 2000000

### 默认的 TCP 数据接收窗口大小(字节)
net.core.rmem_default = 699040

### 最大的 TCP 数据接收窗口(字节)
net.core.rmem_max = 50331648

### 默认的 TCP 数据发送窗口大小(字节)
net.core.wmem_default = 131072

### 最大的 TCP 数据发送窗口(字节)
net.core.wmem_max = 33554432

### 定义了每个系统端口最大的监听队列长度,这是一个全局参数
net.core.somaxconn = 65535

### TCP/UDP 协议允许使用的本地端口号
net.ipv4.ip_local_port_range = 1025 65534

### 开启允许绑定非本机的 IP
net.ipv4.ip_nonlocal_bind = 1

### 对于本端断开的 Socket 连接,TCP 保持在 FIN-WAIT-2 状态的时间(秒)
net.ipv4.tcp_fin_timeout = 7

### TCP 发送 keepalive 探测消息的间隔时间(秒),用于确认 TCP 连接是否有效
net.ipv4.tcp_keepalive_time = 300

### 限制仅仅是为了防止简单的 DoS 攻击
net.ipv4.tcp_max_orphans = 3276800

### 还未获得对端确认的连接请求,可保存在队列中的最大数目
net.ipv4.tcp_max_syn_backlog = 655360

### 系统同时保持 TIME_WAIT 套接字的最大数量
net.ipv4.tcp_max_tw_buckets = 6000000

### 确定 TCP 栈应该如何反映内存使用,每个值的单位都是内存页(通常是 4KB)
### 第一个值是内存使用的下限;
### 第二个值是内存压力模式开始对缓冲区使用应用压力的上限;
### 第三个值是内存使用的上限.
net.ipv4.tcp_mem = 94500000 915000000 927000000

### 为自动调优定义 Socket 使用的内存
### 第一个值是为 Socket 接收缓冲区分配的最少字节数;
### 第二个值是默认值(会被 rmem_default 覆盖),缓冲区在系统负载不重的情况下可以增长到这个值;
### 第三个值是接收缓冲区空间的最大字节数(该值会被 rmem_max 覆盖)。
net.ipv4.tcp_rmem = 32768 699040 50331648

### 为自动调优定义 Socket 使用的内存。
### 第一个值是为 Socket 发送缓冲区分配的最少字节数;
### 第二个值是默认值(该值会被 wmem_default 覆盖),缓冲区在系统负载不重的情况下可以增长到这个值;
### 第三个值是发送缓冲区空间的最大字节数(该值会被 wmem_max 覆盖)。
net.ipv4.tcp_wmem = 32768 131072 33554432

### 关闭 tcp 连接传输的慢启动(先休止一段时间,再初始化拥塞窗口)
net.ipv4.tcp_slow_start_after_idle = 0

### 设定在回应 SYN 请求时尝试多少次重新发送初始 SYN, ACK 包后才决定放弃
net.ipv4.tcp_synack_retries = 2

### 表示是否打开 TCP 同步标签(syncookie),同步标签可以防止一个套接字在有过多试图连接到达时引起的过载
### 内核必须打开了 CONFIG_SYN_COOKIES 项进行编译,
net.ipv4.tcp_syncookies = 1

### 在内核放弃建立连接之前发送 SYN 包的数量
net.ipv4.tcp_syn_retries = 2

### 表示开启 TCP 连接中 TIME-WAIT Sockets 的快速回收,默认为 0,表示关闭
net.ipv4.tcp_tw_recycle = 1

### 允许将 TIME-WAIT Sockets 重新用于新的 TCP 连接,默认为 0,表示关闭
net.ipv4.tcp_tw_reuse = 1

### 启用 RFC 1323 定义的 window scaling,要支持超过 64KB 的 TCP 窗口,必须启用该值(1 表示启用)。
### TCP 窗口最大至 1GB,TCP 连接双方都启用时才生效,默认为 1
net.ipv4.tcp_window_scaling = 1

### 最大限度优先,100% 使用物理内存后再考虑使用 Swap
vm.swappiness = 0

同步或异步 I/O 模式问题

根据不同的场景去选择同步还是异步 I/O 模式非常重要,通常的:

  • 在高并发且不关注执行结果的场景中使用异步 I/O 模式。
  • 在对程序执行的稳定性、对执行结果响应的准确性都有很高要求的场景下使用同步 I/O 模式,并且需要保证每次 send 和 recv 的原子性。

Session 过期问题

为了更高效的进行数据传输,程序往往会在一个 Socket Connection 中维护多个 Sessions。此时,除了需要考虑 Conection 的状态之外,还需要考虑 Session 是否过期的问题。

通常的,我们需要在 Send 和 Receive 之前,首先检查 Connection 是否 ACTIVE,然后检查 Session 是否过期:

  • 如果连接失效:则重新建立 Connection,并且重新创建 Session。
  • 如果连接有效,但会话过期:则重新创建 Session。
  • 如果连接有效,会话有效:则继续发送或接收。

以上是关于C 语言网络编程 — 高并发 TCP 网络服务器的主要内容,如果未能解决你的问题,请参考以下文章

C语言socket高并发网络编程

马哥高端Go语言百万并发高薪班微服务分布式高可用Go高并发(低价网盘分享)

聊聊缓存

Linux下高并发网络编程

Python网络编程之高级篇二

JAVA高并发网络编程之TCP和UDP协议