目录
文章目录
- 目录
- TCP Socket 编程示例
- 服务端
- 客户端
- 测试
- 高并发 TCP 网络服务器
- I/O 并发模型设计
- 系统文件描述符数量限制
- 完全断开连接导致的性能问题
- 关注 TCP 连接的状态
- 合理配置 TCP 连接内核参数
- 使用 shutdown() 来确保 Connection 被正常关闭
- 断开重连问题
- 使用 Heartbeat 来判断 Connection 是否 ACTIVE
- 使用 select() 来进行 Heartbeat 心跳检查
- 数据缓冲问题
- 同步或异步 I/O 模式问题
- Session 过期问题
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
运行:
- 先启动 TCP Server:
$ ./tcp_server
- 查看监听 Socket 是否绑定成功:
$ netstat -lpntu | grep 6666
tcp 0 0 0.0.0.0:6666 0.0.0.0:* LISTEN 28675/./tcp_server
- 启动 TCP Client
$ ./tcp_client
高并发 TCP 网络服务器
I/O 并发模型设计
-
多进程模型:主进程负责 Listen 和 Accept 连接请求;Accept 后,就 fock 子进程来处理 read() 和 write()。缺点是,多进程数量有限,消耗资源也多。
-
多线程模型:使用线程来处理 read() 和 write() 会更加高效。但无论是使用多进程还是多线程,如果一个 TCP 连接只对应了一个进程或线程,就很难逃脱 C10K 的问题。
-
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(数量限制)。在 Shell 中可以通过 ulimit 指令来查看并设置:
- 进程级别限制:fs.nr_open
- 系统级别限制:fs.file-max
fs.nr_open 总是应该小于等于 fs.file-max,同时这两个值越大,系统消耗的资源就越多,所以需要根据实际情况来进行设置。
完全断开连接导致的性能问题
四次挥手是一个冗长的过程,由于网络环境的复杂性与 TCP 连接的可靠性相违背,所以在某些特殊的场景中会出现相应的问题。比较常见的就是在高并发网络服务器场景中,由于 “完全断开连接“ 导致的性能问题。
TCP 协议规定,C/S 双方必须完整进行四次挥手,进入到 CLOSED 状态,各自的 Kernel 才会完全释放 Socket 资源。如果由于网络连通性或其他原因导致四次挥手没有完成,那么这个 Socket 的连接就会处于假死状态,并且继续占用系统资源。
具体而言,有以下几种情况:
-
TCP 协议规定 TIME_WAIT 状态会持续 240s(2MSL),以此来保证后面新建的连接不会受到旧连接残留的延迟重发报文的影响。所以,高并发的网络服务器通常不应该主动 close(),而是让对方主动,避免出现大量的 TIME_WAIT 连接占用系统资源。
-
TCP 协议规定 FIN_WAIT_2 状态有 60s(默认)超时等待时间,如果对方一直不 close(),那么 FIN_WAIT_2 也会一致占用系统资源。
-
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。
使用 Heartbeat 来判断 Connection 是否 ACTIVE
Connection 状态检测是服务端的特性,服务端以此来决定回收 Socket 资源,或者执行断开重连。而客户端只需要重新连接、重新发送即可。
问题是,初始情况下,服务端无法有效的区分客户端目前是处于 “长期空闲” 还是 “下线“ 状态。解决这个问题的思路就是通过建立 Heartbeat 心跳协议,让客户端始终忙碌,以此来排除掉客户端 “长期空闲“ 的情况。
另外,考虑到服务端的压力,Heartbeat 特性通常考虑由客户端来实现。
使用 select() 来进行 Heartbeat 心跳检查
select() 是异步的非阻塞 I/O 接口,更适用于 Heartbeat 心跳检查场景。
数据缓冲问题
send 和 recv 函数都具有数据缓冲特性。
以 recv 为例,如果发送方的数据量超出了接收方一次所允许的最大接收量,那么数据就会被截断,并将剩余的数据缓冲在接收方。然后,当接收方再次调用 recv 函数时,剩余的数据才会从缓冲区取出。经常的,为了得到一个完整的响应结果可能需要调用多次 recv 函数。
缓冲特性引入了一个 “数据完整性“ 的问题,需有程序自行保证 send 和 recv 的完整性。
同步或异步 I/O 模式问题
根据不同的场景去选择同步还是异步 I/O 模式非常重要,通常的:
- 在高并发且不关注执行结果的场景中使用异步 I/O 模式。
- 在对程序执行的稳定性、对执行结果响应的准确性都有很高要求的场景下使用同步 I/O 模式,并且需要保证每次 send 和 recv 的原子性。
Session 过期问题
为了更高效的进行数据传输,程序往往会在一个 Socket Connection 中维护多个 Sessions。此时,除了需要考虑 Conection 的状态之外,还需要考虑 Session 是否过期的问题。
通常的,我们需要在 Send 和 Receive 之前,首先检查 Connection 是否 ACTIVE,然后检查 Session 是否过期:
- 如果连接失效:则重新建立 Connection,并且重新创建 Session。
- 如果连接有效,但会话过期:则重新创建 Session。
- 如果连接有效,会话有效:则继续发送或接收。