C++网络编程

tiny_star Lv3

本文介绍网络编程的基础知识,socket 的库函数,网络通讯的原理,TCP缓存,I/O 复用的模型(select、poll、epoll),非阻塞的I/O

第一个网络通讯程序

image-20251005172050573

客户/服务器

  • 网络通讯是指两台计算机中的程序进行传输数据的过程
  • 客户程序(端):指主动发起通讯的程序
  • 服务程序(端/器):指被动的等待,然后为向它发起通讯的客户端提供服务
  • 客户端必须提前知道服务端的 IP 地址和通讯端口
  • 服务端不需要知道客户端的 IP 地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
/*
* 程序名:demo1.cpp,此程序用于演示 socket 的客户端
*/
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
using namespace std;

int main(int argc, char* argv[])
{
if(argc != 3)
{
cout << "Using: ./demo1 服务端的 IP 服务端的端口 \nExample: ./demo1 192.168.112.131 5005\n\n";
return -1;
}

// 第1步:创建客户端的 socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if(sockfd == -1)
{
perror("socket");
return -1;
}

// 第2步:向服务器发起连接请求
struct hostent* h; // 用于存放服务器 IP 的数据结构
if((h = gethostbyname(argv[1])) == 0) // 指定服务端的 ip 地址
{
cout << "gethostbyname failed.\n" << endl;
close(sockfd);
return -1;
}
struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(atoi(argv[2])); // 指定服务端的通信端口
memcpy(&servaddr.sin_addr, h->h_addr, h->h_length);
if(connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) != 0) // 向服务端发起连接请求
{
perror("connect");
close(sockfd);
return -1;
}

// 第3步:与服务端通信,发送一个请求报文后等待回复,然后再发下一个请求报文
char buffer[1024];
for(int ii=0; ii < 3; ii++) // 循环3次,将与服务端进行三次通讯
{
int iret;
memset(buffer, 0, sizeof(buffer));
sprintf(buffer, "这是第 %d 个报文,编号 %03d", ii + 1, ii + 1); // 生成请求报文内容
// 向服务端发送请求报文,在网络传输数据的过程中,可以使用文件的 I/O 函数
if((iret=write(sockfd, buffer, strlen(buffer))) <= 0)
// if((iret=send(sockfd, buffer, strlen(buffer), 0)) <= 0)
{
perror("send");
break;
}
cout << "发送:" << buffer << endl;

memset(buffer, 0, sizeof(buffer));
// 接收服务端的回应报文,如果服务端没有发送回应报文,recv() 函数将阻塞等待,在网络传输数据的过程中,可以使用文件的 I/O 函数
if((iret=read(sockfd, buffer, sizeof(buffer))) <= 0)
//if((iret=recv(sockfd, buffer, sizeof(buffer), 0)) <= 0)
{
cout << "iret=" << iret << endl;
break;
}
cout << "接收:" << buffer << endl;

sleep(1);
}

// 第4步:关闭 socket,释放资源
close(sockfd);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
/*
* 程序名:demo2.cpp,此程序用于演示 socket 通信的服务端
*/
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
using namespace std;

int main(int argc, char* argv[])
{
if(argc != 2)
{
cout << "Using: ./demo2 通讯端口 \nExample: ./demo2 5005\n\n";
cout << "注意:运行服务端程序的 Linux 系统的防火墙必须要开通5005端口\n";
cout << " 如果是云服务器,还要开通云平台的访问策略\n\n";
}

// 第1步:创建服务端的 socket
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
if(listenfd == -1)
{
perror("socket");
return -1;
}

// 第2步:把服务端用于通信的 IP 和端口绑定到 socket 上
struct sockaddr_in servaddr; // 用于存放服务端 IP 和端口的数据结构
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET; // 指定协议
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 服务端任意网卡的 IP 都可以用于通讯
servaddr.sin_port = htons(atoi(argv[1])); // 指定通信端口,普通用户只能用1024以上的端口
// 绑定服务端的 IP 和端口
if(bind(listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) != 0)
{
perror("bind");
close(listenfd);
return -1;
}

// 第3步:把 socket 设置为可连接(监听)的状态
if(listen(listenfd, 5) != 0)
{
perror("listen");
close(listenfd);
return -1;
}

// 第4步:受理客户端的连接请求,如果没有客户端连上来,accept() 函数将阻塞等待
int clientfd = accept(listenfd, 0, 0);
if(clientfd == -1)
{
perror("accept");
close(listenfd);
return -1;
}

cout << "客户端已连接\n";

// 第5步:与客户端通信,接收客户端发过来的报文后,回复ok
char buffer[1024];
while(true)
{
int iret;
memset(buffer, 0, sizeof(buffer));
// 接收客户端的请求报文,如果客户端没有发送请求报文,recv() 函数将阻塞等待
// 如果客户端已断开连接,recv() 函数将返回0
if((iret = recv(clientfd, buffer, sizeof(buffer), 0)) <= 0)
{
cout << "iret=" << iret << endl;
break;
}
cout << "接收:" << buffer << endl;

strcpy(buffer, "ok"); // 生成回应报文内容
// 向客户端发送回应报文
if((iret=send(clientfd, buffer, strlen(buffer), 0)) <= 0)
{
perror("send");
break;
}
cout << "发送:" << buffer << endl;
}

// 第6步:关闭 socket,释放资源
close(listenfd);
close(clientfd);
}
1
2
3
4
5
6
7
8
// 客户端 终端
[zxc@study CppNetworkProgramming]$ ./demo1 192.168.112.131 5005
发送:这是第 1 个报文,编号 001
接收:ok
发送:这是第 2 个报文,编号 002
接收:ok
发送:这是第 3 个报文,编号 003
接收:ok
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 服务端 终端
[zxc@study CppNetworkProgramming]$ ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
inet6 ::1/128 scope host
valid_lft forever preferred_lft forever
2: ens33: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
link/ether 00:0c:29:43:d5:19 brd ff:ff:ff:ff:ff:ff
inet 192.168.112.131/24 brd 192.168.112.255 scope global noprefixroute dynamic ens33
valid_lft 1323sec preferred_lft 1323sec
inet6 fe80::9ed2:1872:20e3:d79a/64 scope link noprefixroute
valid_lft forever preferred_lft forever
3: virbr0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN group default qlen 1000
link/ether 52:54:00:d1:15:4a brd ff:ff:ff:ff:ff:ff
inet 192.168.122.1/24 brd 192.168.122.255 scope global virbr0
valid_lft forever preferred_lft forever
4: virbr0-nic: <BROADCAST,MULTICAST> mtu 1500 qdisc pfifo_fast master virbr0 state DOWN group default qlen 1000
link/ether 52:54:00:d1:15:4a brd ff:ff:ff:ff:ff:ff
[zxc@study CppNetworkProgramming]$ ./demo2 5005
客户端已连接
接收:这是第 1 个报文,编号 001
发送:ok
接收:这是第 2 个报文,编号 002
发送:ok
接收:这是第 3 个报文,编号 003
发送:ok
iret=0

客户端和服务端程序可以运行在不同的虚拟机上,也可以运行在同一个虚拟机上,网络通信的客户端和服务端是逻辑的概念,与物理计算机和操作系统没有关系

基于 Linux 的文件操作

  • 对 Linux 来说,socket 操作与文件操作没有区别
  • 在网络传输数据的过程中,可以使用文件的 I/O 函数
  • 文件描述符是 Linux 分配给文件或 socket 的整数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// demo3.cpp,本程序演示了 Linux 底层文件的操作——创建文件并写入数据
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>

int main()
{
int fd; // 定义一个文件描述符/文件句柄

fd = open("data.txt", O_CREAT|O_RDWR|O_TRUNC); // 打开文件 O_CREAT 创建文件 O_RDWR 可读可写 O_TRUNC 是否截断文件
if(fd == -1)
{
perror("open(data.txt)");
return -1;
}

printf("文件描述符 fd=%d\n", fd);

char buffer[1024];
strcpy(buffer, "我是渺如星辰\n");

if(write(fd, buffer, strlen(buffer)) == -1) // 把数据写入文件
{
perror("write()");
return -1;
}

close(fd); // 关闭文件
}
1
2
3
4
5
6
7
8
// 终端 
[zxc@study CppNetworkProgramming]$ g++ -g -o demo3 demo3.cpp
[zxc@study CppNetworkProgramming]$ ./demo3
文件描述符 fd=3
[zxc@study CppNetworkProgramming]$ ls
data.txt demo1 demo1.cpp demo2 demo2.cpp demo3 demo3.cpp demo4.cpp
[zxc@study CppNetworkProgramming]$ cat data.txt
我是渺如星辰
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// demo4.cpp,本程序演示了 Linux 底层文件的操作——读取文件
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>

int main()
{
int fd; // 定义一个文件描述符/文件句柄

fd = open("data.txt", O_RDONLY); // 打开文件
if(fd==-1)
{
perror("open(data.txt)");
return -1;
}

printf("文件描述符 fd=%d\n", fd);

char buffer[1024];
memset(buffer, 0, sizeof(buffer));
if(read(fd, buffer, sizeof(buffer)) == -1) // 从文件中读取数据
{
perror("write()");
return -1;
}

printf("%s", buffer);

close(fd); // 关闭文件
}
1
2
3
4
5
// 终端
[zxc@study CppNetworkProgramming]$ g++ -g -o demo4 demo4.cpp
[zxc@study CppNetworkProgramming]$ ./demo4
文件描述符 fd=3
我是渺如星辰

文件描述符的分配规则

  • /proc/进程id/fd目录中,存放了每个进程打开的 fd
  • Linux 进程默认打开了三个文件描述符:0-标准输入(键盘),1-标准输出(显示器),2-标准错误(显示器) cin cout cerr
  • 文件描述符的分配规则是:找到最小的,没有被占用的文件描述符

socket 函数详解

协议

协议是网络通讯的规则,是约定

创建 socket

1
2
3
4
5
int socket(int domain, int type, int protocol);
// 成功返回一个有效的 socket,失败返回-1,errno 被设置
// 网络编程全部的函数,基本上都是失败返回-1,errno 被设置
// 只要参数没填错,基本上不会失败
// 不过,单个进程中创建的 socket 数量受系统参数 open files 的限制(ulimit -a)
  1. domain 通讯的协议家族
    PF_INET IPv4 互联网协议族
    PF_INET6 IPv6 互联网协议族
    PF_LOCAL 本地通信的协议族
    PF_PACKET 内核底层的协议族
    PF_IPX IPX Novell 协议族
    IPv6 尚未普及,其它的不常用

  2. type 数据传输的类型
    SOCK_STREAM 面向连接的 socket:数据不会丢失;数据的顺序不会错乱;双向通道
    SOCK_DGRAM 无连接的 socket:数据可能会丢失;数据的顺序可能会错乱;传输的效率更高

  3. protocol 最终使用的协议
    在 IPv4 网络协议家族中,数据传输方式为 SOCK_STREAM 的协议只有 IPPROTO_TCP,数据传输方式为 SOCK_DGRAM 的协议只有 IPPROTO_UDP
    本参数也可以填0

    1
    2
    socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);		// 创建 tcp 的 sock,第三个参数也可以填0,编译器可以自动识别
    socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP); // 创建 udp 的 sock,第三个参数也可以填0,编译器可以自动识别

TCP和UDP

  1. TCP 和 UDP 的区别
    TCP
    TCP 面向连接,通过三次握手建立连接,四次挥手断开连接
    TCP 是可靠的通信方式,通过超时重传、数据校验等方式来确保数据无差错,不丢失,不重复,并且按序到达
    TCP 把数据当成字节流,当网络出现波动时,连接可能出现响应延迟的问题
    TCP 只支持点对点通信
    TCP 报文的首部较大,为20字节
    TCP 是全双工的可靠信道
    UDP
    UDP 是无连接的,即发送数据之前不需要建立连接,这种方式为 UDP 带来了高效的传输效率,但也导致无法确保数据的发送成功
    UDP 以最大的速率进行传输,但不保证可靠交付,会出现丢失、重复等等问题
    UDP 没有拥塞控制,当网络出现拥塞时,发送方不会降低发送速率
    UDP 支持一对一,一对多,多对一和多对多的通信
    UDP 报文的首部比较小,只有8字节
    UDP 是不可靠信道
  2. TCP 保证自身可靠的方式
    1)数据分片:在发送端对用户数据进行分片,在接收端进行重组,由TCP确定分片的大小并控制分片和重组
    2)到达确认:接收端接收到分片数据时,根据分片的序号向对端回复一个确认包
    3)超时重发:发送方在发送分片后开始计时,若超时却没有收到对端的确认包,将会重发分片
    4)滑动窗口:TCP 中采用滑动窗口来进行传输控制,发送方可以通过滑动窗口的大小来确定应该发送多少字节的数据。当滑动窗口为0时,发送方不会再发送数据
    5)失序处理:TCP 的接收端会把接收到的数据重新排序
    6)重复处理:如果传输的分片出现重复,TCP 的接收端会丢弃重复的数据
    7)数据校验:TCP 通过数据的校验和来判断数据在传输过程中是否正确
  3. UDP 不可靠的原因
    没有上述 TCP 的机制,如果校验和出错,UDP 会将该报文丢弃
  4. TCP 和 UDP 使用场景
    TCP 使用场景
    TCP 实现了数据传输过程中的各种控制,适合对可靠性有要求的场景
    UDP 使用场景
    可以容忍数据丢失的场景:
    1)视频、音频等多媒体通信(即时通信)
    2)广播信息
  5. UDP 能实现可靠传输吗
    如果用 UDP 实现可靠传输,应用程序必须实现重传和排序等功能,非常麻烦,还不如直接用TCP

主机字节序与网络字节序

大端序/小端序

如果数据类型占用的内存空间大于1字节,CPU 把数据存放在内存中的方式有两种
大端序(Big Endian):低位字节存放在高位,高位字节存放在低位
小端序(Little Endian):低位字节存放在地位,高位字节存放在高位
假设从内存地址 0x00000001 处开始存储十六进制数 0x12345678,那么
Big-Endian(按原来顺序存储)
0x00000001 0x12
0x00000002 0x34
0x00000003 0x56
0x00000004 0x78
Little-Endian(颠倒顺序存储)
0x00000001 0x78
0x00000002 0x56
0x00000003 0x34
0x00000004 0x12
Intel 系列的 CPU 以小端序方式保存数据,其它型号的 CPU 不一定
操作文件的本质是把内存中的数据写入磁盘,在网络编程中,传输数据的本质也是把数据写入文件(socket 也是文件描述符)
这样的话,字节序不同的计算机之间传输数据,可能会出现问题

网络字节序

为了解决不同字节序的计算机之间传输数据的问题,约定采用网络字节序(大端序)
C 语言提供了四个库函数,用于在主机字节序和网络字节序之间转换

1
2
3
4
5
6
7
8
9
uint16_t htons(uint16_t hostshort);		// uint16_t	2字节的整数 unsigned short
uint32_t htonl(uint32_t hostlong); // uint32_t 4字节的整数 unsigned int
uint16_t ntohs(uint16_t netshort);
uint32_t ntohl(uint32_t netlong);
// h host(主机)
// to 转换
// n network(网络)
// s short(2字节,16位的整数)
// l long(4字节,32位的整数)

IP 地址和通讯端口

在计算机中,IPv4 的地址用4字节的整数存放,通讯端口用2字节的整数(0-65535)存放

如何处理大小端序

在网络编程中,数据收发的时候中有自动转换机制,不需要程序员手动转换,只有向 sockaddr_in 结构体成员变量填充数据时,才需要考虑字节序的问题

sockaddr 结构体

sockaddr 结构体

存放协议族、端口和地址信息,客户端的 connect() 函数和服务端的 bind() 函数需要这个结构体

1
2
3
4
struct sockaddr {
unsigned short sa_family; // 协议族,与 socket() 函数的第一个参数相同,填 AF_INET
unsigned char sa_data[14]; // 14字节的端口和地址,预留几个字节方便以后扩展
};

sockaddr_in 结构体

sockaddr 结构体是为了统一地址结构的表示方法,统一接口函数,但是操作不方便,所以定义了等价的 sockaddr_in 结构体,它的大小与 sockaddr 相同,可以强制转换成 sockaddr

1
2
3
4
5
6
7
8
9
10
struct sockaddr_in{
unsigned short sin_family; // 协议族,与 socket() 函数的第一个参数相同,填 AF_INET
unsigned short sin_port; // 16位端口号,用 htons() 把端口号转换
struct in_addr sin_addr; // 32位的地址,IP 地址的结构体
unsigned char sin_zero[8]; // 未使用,为了保持与 struct sockaddr 一样的长度而添加
};

struct in_addr{ // IP 地址的结构体
unsigned int s_addr; // 32位的 IP 地址,大端序
};

gethostbyname 函数

根据域名/主机名/字符串 IP 获取大端序 IP,用于网络通讯的客户端程序中

1
2
3
4
5
6
7
8
9
struct hostent *gethostbyname(const char *name);
struct hostent{
char *h_name; // 主机名
char **h_aliases; // 主机所有别名构成的字符串数组,同一 IP 可绑定多个域名
short h_addrtype; // 主机 IP 地址的类型,例如 IPv4 (AF_INET) 还是 IPv6
short h_length; // 主机 IP 地址长度,IPv4地址为4,IPv6地址则为16
char **h_addr_list; // 主机的 IP 地址,以网络字节序存储
};
#define h_addr h_addr_list[0] // for backward compatibility

转换后,用以下代码把大端序的地址复制到 sockaddr_in 结构体的 sin_addr 成员中

1
memcpy(&servaddr.sin_addr, h->h_addr, h->h_length);

字符串 IP 与大端序 IP 的转换

C语言提供了几个库函数,用于字符串格式的 IP 和大端序 IP 的互相转换,用于网络通讯的服务端程序中

1
2
3
4
5
6
7
8
9
10
typedef unsigned int in_addr_t;			// 32位大端序的 IP 地址

// 把字符串格式的 IP 转换成大端序的 IP,转换后的 IP 值赋给 sockaddr_in.in_addr.s_addr,只能用 IP,不能用域名和主机名
in_addr_t inet_addr(const char *cp);

// 把字符串格式的 IP 转换成大端序的 IP,转换后的 IP 填充到 sockaddr_in.in_addr 成员
int inet_aton(const char *cp, struct in_addr *inp);

// 把大端序 IP 转换成字符串格式的 IP,用于在服务端程序中解析客户端的 IP 地址
char *inet_ntoa(struct in_addr in);

封装 socket

  • 网络编程涉及到多个数据结构和函数,使用起来不方便
  • 把客户端程序用到的数据结构和函数封装成 ctcpclient 类
  • 把服务端程序用到的数据结构和函数封装成 ctcpserver 类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
/*
* 程序名:demo5.cpp,此程序用于演示封装 socket 通讯的客户端
*/
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
using namespace std;

class ctcpclient // TCP 通讯的客户端类
{
private:
int m_clientfd; // 客户端的 socket,-1表示未连接或连接已断开;>=0表示有效的 socket
string m_ip; // 服务端的 IP /域名
unsigned short m_port; // 通讯端口

public:
ctcpclient() : m_clientfd(-1) {}

// 向服务端发起连接请求,成功返回 true,失败返回 false
bool connect(const string &in_ip, const unsigned short in_port)
{
if(m_clientfd != -1) return false; // 如果 socket 已连接,直接返回失败

// 把服务端的 IP 和端口保存到成员变量中
m_ip = in_ip;
m_port = in_port;

// 第1步:创建客户端的 socket
if((m_clientfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
{
return false;
}

// 第2步:向服务器发起连接请求
struct sockaddr_in servaddr; // 用于存放协议、端口和 IP 地址的结构体
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET; // 1)协议族,固定填 AF_INET
servaddr.sin_port = htons(m_port); // 2)指定服务端的通信端口

struct hostent* h; // 用于存放服务器 IP 地址(大端序)的结构体的指针
if((h = gethostbyname(m_ip.c_str())) == nullptr) // 把域名/主机名/字符串格式的 IP 转换成结构体
{
::close(m_clientfd); // 全局 close 库函数
m_clientfd = -1;
return false;
}
memcpy(&servaddr.sin_addr, h->h_addr, h->h_length); // 3)指定服务端的 IP(大端序)

// 向服务端发起连接请求
if(::connect(m_clientfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) == -1) // 全局 connect 库函数
{
::close(m_clientfd); // 全局 close 库函数
m_clientfd = -1;
return false;
}

return true;
}

// 向服务端发送报文,成功返回 true,失败返回 false
bool send(const string &buffer) // buffer 不要用 const char *
{
if(m_clientfd == -1) return false; // 如果 socket 的状态是未连接,直接返回失败

if((::send(m_clientfd, buffer.data(), buffer.size(), 0)) <= 0) return false; // 全局 send 库函数

return true;
}

// 接收服务端的报文,成功返回 true,失败返回 false
// buffer-存放接收到的报文的内容,maxlen-本次接收报文的最大长度
bool recv(string &buffer, const size_t maxlen)
{ // 如果直接操作 string 对象的内存,必须保证:1 不能越界 2 操作后手动设置数据的大小
buffer.clear(); // 清空容器
buffer.resize(maxlen); // 设置容器的大小为 maxlen
int readn = ::recv(m_clientfd, &buffer[0], buffer.size(), 0); // 直接操作 buffer 的内存,全局 recv 库函数
if(readn <= 0)
{
buffer.clear();
return false;
}
buffer.resize(readn); // 重置 buffer 的实际大小

return true;
}

// 断开与服务端的连接
bool close()
{
if(m_clientfd == -1) return false; // 如果 socket 的状态是未连接,直接返回失败

::close(m_clientfd);
m_clientfd = -1;
return true;
}
~ctcpclient()
{
close();
}
};

int main(int argc, char* argv[])
{
if(argc != 3)
{
cout << "Using: ./demo5 服务端的 IP 服务端的端口 \nExample: ./demo5 192.168.112.131 5005\n\n";
return -1;
}

ctcpclient tcpclient;
if(tcpclient.connect(argv[1], atoi(argv[2])) == false) // 向服务端发起连接请求
{
perror("connect()");
return -1;
}

// 第3步:与服务端通信,客户发送一个请求报文后等待服务端的回复,收到回复后,再发下一个请求报文
string buffer;
for(int ii = 0; ii < 3; ii++) // 循环3次,将与服务端进行三次通讯
{
buffer = "这是第" + to_string(ii + 1) + "个报文,编号" + to_string(ii + 1); // 生成请求报文内容
// 向服务端发送请求报文
if(tcpclient.send(buffer) == false)
{
perror("send");
break;
}
cout << "发送:" << buffer << endl;

// 接收服务端的回应报文,如果服务端没有发送回应报文,recv() 函数将阻塞等待
if(tcpclient.recv(buffer, 1024) == false)
{
perror("recv()");
break;
}
cout << "接收:" << buffer << endl;

sleep(1);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
/*
* 程序名:demo6.cpp,此程序用于演示封装 socket 通讯的服务端
*/
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
using namespace std;

class ctcpserver // TCP通讯的服务端类
{
private:
int m_listenfd; // 监听的 socket, -1表示未初始化
int m_clientfd; // 客户端连上来的 socket,-1表示客户端未连接
string m_clientip; // 客户端字符串格式的 IP
unsigned short m_port; // 服务端用于通讯的端口
public:
ctcpserver() : m_listenfd(-1), m_clientfd(-1) {}

// 初始化服务端用于监听的 socket
bool initserver(const unsigned short in_port)
{
// 第1步:创建服务端的 socket
if((m_listenfd = socket(AF_INET, SOCK_STREAM, 0)) == -1) return false;

m_port = in_port;

// 第2步:把服务端用于通信的 IP 和端口绑定到 socket 上
struct sockaddr_in servaddr; // 用于存放协议、端口和 IP 地址的结构体
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET; // 1 协议族,固定填 AF_INET
servaddr.sin_port = htons(m_port); // 2 指定服务端的通信端口
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 3 如果操纵系统有多个 IP,全部的 IP 都可以用于通讯

// 绑定服务端的 IP 和端口(为 socket 分配 IP 和端口)
if(bind(m_listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) == -1)
{
close(m_listenfd);
m_listenfd = -1;
return false;
}

// 第3步:把 socket 设置为可连接(监听)的状态
if(listen(m_listenfd, 5) == -1)
{
close(m_listenfd);
m_listenfd = -1;
return false;
}

return true;
}

// 受理客户端的连接(从已连接的客户端中取出一个客户端),如果没有已连接的客户端,accept() 函数将阻塞等待
bool accept()
{
struct sockaddr_in caddr; // 客户端的地址信息
socklen_t addrlen = sizeof(caddr); // struct sockaddr_in 的大小
if((m_clientfd = ::accept(m_listenfd, 0, 0)) == -1) return false; // 全局 accept 库函数

m_clientip = inet_ntoa(caddr.sin_addr); // 把客户端的地址从大端序转换成字符串

return true;
}

// 获取客户端的 IP(字符串格式)
const string& clientip() const
{
return m_clientip;
}

// 向对端发送报文,成功返回 true,失败返回 false
bool send(const string& buffer)
{
if(m_clientfd == -1) return false;

if((::send(m_clientfd, buffer.data(), buffer.size(), 0)) <= 0) return false; // 全局 send 库函数

return true;
}

// 接收对端的报文,成功返回 true,失败返回 false
// buffer-存放接收到的报文的内容,maxlen-本次接收报文的最大长度
bool recv(string& buffer, const size_t maxlen)
{
buffer.clear(); // 清空容器
buffer.resize(maxlen); // 设置容器的大小为 maxlen
int readn = ::recv(m_clientfd, &buffer[0], buffer.size(), 0); // 直接操作 buffer 的内存,全局 recv 库函数
if(readn <= 0)
{
buffer.clear();
return false;
}
buffer.resize(readn); // 重置 buffer 的实际大小

return true;
}

// 关闭监听的 socket
bool closelisten()
{
if(m_listenfd == -1) return false;

close(m_listenfd);
m_listenfd = -1;
return true;
}

// 关闭客户端连上来的 socket
bool closeclient()
{
if(m_clientfd == -1) return false;

close(m_clientfd);
m_clientfd = -1;
return true;
}

~ctcpserver()
{
closelisten();
closeclient();
}
};

int main(int argc, char* argv[])
{
if(argc != 2)
{
cout << "Using: ./demo2 通讯端口 \nExample: ./demo2 5005\n\n";
cout << "注意:运行服务端程序的 Linux 系统的防火墙必须要开通5005端口\n";
cout << " 如果是云服务器,还要开通云平台的访问策略\n\n";
return -1;
}

ctcpserver tcpserver;
if(tcpserver.initserver(atoi(argv[1])) == false) // 初始化服务端用于监听的 socket
{
perror("initserver()");
return -1;
}

// 受理客户端的连接(从已连接的客户端中取出一个客户端),如果没有已连接的客户端,accept() 函数将阻塞等待
if(tcpserver.accept() == false)
{
perror("accept()");
return -1;
}
cout << "客户端已连接(" <<tcpserver.clientip() << ")\n";

string buffer;
while(true)
{
// 接收对端的报文,如果对端没有发送报文,recv() 函数将阻塞等待
if(tcpserver.recv(buffer, 1024) == false)
{
perror("recv()");
break;
}
cout << "接收:" << buffer << endl;

buffer = "ok"; // 生成回应报文内容
// 向对端发送报文
if(tcpserver.send(buffer) == false)
{
perror("send");
break;
}
cout << "发送:" << buffer << endl;
}
}

多进程的服务端

在多进程的服务程序中,如果子进程收到退出信号,子进程自行退出,如果父进程收到退出信号,则应该先向全部的子进程发送退出信号,然后自己再退出
父进程只受理客户端的连接,不与客户端通讯
子进程负责与客户端通讯,不受理客户端的连接
fork 之后,父进程不需要 tcpserver.m_clientfd,子进程不需要 tcpserver.m_listenfd

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
/*
* 程序名:demo7.cpp,此程序用于演示多进程的 socket 服务端
*/
#include <iostream>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <signal.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
using namespace std;

class ctcpserver // TCP通讯的服务端类
{
private:
int m_listenfd; // 监听的 socket, -1表示未初始化
int m_clientfd; // 客户端连上来的 socket,-1表示客户端未连接
string m_clientip; // 客户端的 IP
unsigned short m_port; // 服务端用于通讯的端口
public:
ctcpserver() : m_listenfd(-1), m_clientfd(-1) {}

// 初始化服务端用于监听的 socket
bool initserver(const unsigned short in_port)
{
// 第1步:创建服务端的 socket
if((m_listenfd = socket(AF_INET, SOCK_STREAM, 0)) == -1) return false;

m_port = in_port;

// 第2步:把服务端用于通信的 IP 和端口绑定到 socket 上
struct sockaddr_in servaddr; // 用于存放协议、端口和 IP 地址的结构体
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET; // 1 协议族,固定填 AF_INET
servaddr.sin_port = htons(m_port); // 2 指定服务端的通信端口
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 3 如果操纵系统有多个 IP,全部的 IP 都可以用于通讯

// 绑定服务端的 IP 和端口(为 socket 分配 IP 和端口)
if(bind(m_listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) == -1)
{
close(m_listenfd);
m_listenfd = -1;
return false;
}

// 第3步:把 socket 设置为可连接(监听)的状态
if(listen(m_listenfd, 5) == -1)
{
close(m_listenfd);
m_listenfd = -1;
return false;
}

return true;
}

// 受理客户端的连接(从已连接的客户端中取出一个客户端),如果没有已连接的客户端,accept() 函数将阻塞等待
bool accept()
{
struct sockaddr_in caddr; // 客户端的地址信息
socklen_t addrlen = sizeof(caddr); // struct sockaddr_in 的大小
if((m_clientfd = ::accept(m_listenfd, 0, 0)) == -1) return false; // 全局 accept 库函数

m_clientip = inet_ntoa(caddr.sin_addr); // 客户端的地址从大端序转换成字符串

return true;
}

// 获取客户端的 IP(字符串格式)
const string& clientip() const
{
return m_clientip;
}

// 向对端发送报文,成功返回 true,失败返回 false
bool send(const string& buffer)
{
if(m_clientfd == -1) return false;

if((::send(m_clientfd, buffer.data(), buffer.size(), 0)) <= 0) return false; // 全局 send 库函数

return true;
}

// 接收对端的报文,成功返回 true,失败返回 false
// buffer-存放接收到的报文的内容,maxlen-本次接收报文的最大长度
bool recv(string& buffer, const size_t maxlen)
{
buffer.clear(); // 清空容器
buffer.resize(maxlen); // 设置容器的大小为 maxlen
int readn = ::recv(m_clientfd, &buffer[0], buffer.size(), 0); // 直接操作 buffer 的内存,全局 recv 库函数
if(readn <= 0)
{
buffer.clear();
return false;
}
buffer.resize(readn); // 重置 buffer 的实际大小

return true;
}

// 关闭监听的 socket
bool closelisten()
{
if(m_listenfd == -1) return false;

close(m_listenfd);
m_listenfd = -1;
return true;
}

// 关闭客户端连上来的 socket
bool closeclient()
{
if(m_clientfd == -1) return false;

close(m_clientfd);
m_clientfd = -1;
return true;
}

~ctcpserver()
{
closelisten();
closeclient();
}
};

ctcpserver tcpserver;

void FathEXIT(int sig); // 父进程的信号处理函数
void ChldEXIT(int sig); // 子进程的信号处理函数

int main(int argc, char* argv[])
{
if(argc != 2)
{
cout << "Using: ./demo7 通讯端口 \nExample: ./demo7 5005\n\n";
cout << "注意:运行服务端程序的 Linux 系统的防火墙必须要开通5005端口\n";
cout << " 如果是云服务器,还要开通云平台的访问策略\n\n";
return -1;
}

// 忽略全部的信号,不希望被打扰,顺便解决了僵尸进程的问题
for(int ii = 1; ii <= 64; ii++) signal(ii, SIG_IGN);

// 设置信号,在 shell 状态下可用 "kill 进程号" 或 "Ctrl+c" 正常终止进程
// 但请不要用 "kill -9 +进程号" 强行终止
signal(SIGTERM, FathEXIT);
signal(SIGINT, FathEXIT); // SIGTERM 15 SIGINT 2

if(tcpserver.initserver(atoi(argv[1])) == false) // 初始化服务端用于监听的 socket
{
perror("initserver()");
return -1;
}

while(true)
{
// 受理客户端的连接(从已连接的客户端中取出一个客户端),如果没有已连接的客户端,accept() 函数将阻塞等待
if(tcpserver.accept() == false)
{
perror("accept()");
return -1;
}

// 每受理一个客户端连接,就 fork 一个子进程,让子进程负责与这个客户端进行通讯,父进程继续受理客户端的连接
int pid = fork();
if(pid == -1) // 系统资源不足
{
perror("fork()");
return -1;
}
if(pid > 0) // 父进程
{
tcpserver.closeclient(); // 父进程关闭客户端连接的 socket
continue; // 父进程返回到循环开始的位置,继续受理客户端的连接
}

tcpserver.closelisten(); // 子进程关闭监听的 socket

// 子进程需要重新设置信号
signal(SIGTERM, ChldEXIT); // 子进程的退出函数与父进程不一样
signal(SIGINT, SIG_IGN); // 子进程不需要捕获 SIGINT 信号

// 子进程负责与客户端进行通讯
cout << "客户端已连接(" <<tcpserver.clientip() << ")\n";

string buffer;
while(true)
{
// 接收对端的报文,如果对端没有发送报文,recv() 函数将阻塞等待
if(tcpserver.recv(buffer, 1024) == false)
{
perror("recv()");
break;
}
cout << "接收:" << buffer << endl;

buffer = "ok"; // 生成回应报文内容
// 向对端发送报文
if(tcpserver.send(buffer) == false)
{
perror("send");
break;
}
cout << "发送:" << buffer << endl;
}

return 0; // 子进程一定要退出,否则又会回到 accept() 函数的位置
}
}

// 父进程的信号处理函数
void FathEXIT(int sig)
{
// 以下代码是为了防止信号处理函数在执行的过程中再次被信号中断
signal(SIGINT, SIG_IGN);
signal(SIGTERM, SIG_IGN);

cout << "父进程退出,sig=" << sig << endl;

kill(0, SIGTERM); // 向全部的子进程发送15的信号,通知它们退出

// 在这里增加释放资源的代码(全局的资源)
tcpserver.closelisten(); // 父进程关闭监听的 socket

exit(0);
}

// 子进程的信号处理函数
void ChldEXIT(int sig)
{
// 以下代码是为了防止信号处理函数在执行的过程中再次被信号中断
signal(SIGINT, SIG_IGN);
signal(SIGTERM, SIG_IGN);

cout << "子进程" << getpid() << "退出,sig=" << sig << endl;

// 在这里增加释放资源的代码(只释放子进程的资源)
tcpserver.closeclient(); // 子进程关闭客户端连上来的 socket

exit(0);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 终端	父进程关闭客户端连接的 socket,子进程关闭监听的 socket
[zxc@study CppNetworkProgramming]$ ps -ef|grep demo7
zxc 40134 38359 0 23:43 pts/1 00:00:00 ./demo7 5005
zxc 40136 40134 0 23:43 pts/1 00:00:00 ./demo7 5005
zxc 40145 39974 0 23:43 pts/3 00:00:00 grep --color=auto demo7
[zxc@study CppNetworkProgramming]$ ls /proc/40134/fd
0 1 2 3
[zxc@study CppNetworkProgramming]$ ls /proc/40136/fd
0 1 2 4
[zxc@study CppNetworkProgramming]$ ps -ef|grep demo7
zxc 40134 38359 0 23:43 pts/1 00:00:00 ./demo7 5005
zxc 40136 40134 0 23:43 pts/1 00:00:00 ./demo7 5005
zxc 40176 40134 0 23:44 pts/1 00:00:00 ./demo7 5005
zxc 40178 39974 0 23:44 pts/3 00:00:00 grep --color=auto demo7
[zxc@study CppNetworkProgramming]$ ls /proc/40134/fd
0 1 2 3
[zxc@study CppNetworkProgramming]$ ls /proc/40136/fd
0 1 2 4
[zxc@study CppNetworkProgramming]$ ls /proc/40176/fd
0 1 2 4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 终端	父进程不关闭客户端连接的 socket,子进程不关闭监听的 socket
[zxc@study CppNetworkProgramming]$ ps -ef|grep demo7
zxc 2774 2717 0 23:53 pts/0 00:00:00 ./demo7 5005
zxc 2945 2774 0 23:54 pts/0 00:00:00 ./demo7 5005
zxc 2948 2822 0 23:55 pts/2 00:00:00 grep --color=auto demo7
[zxc@study CppNetworkProgramming]$ ls /proc/2774/fd
0 1 2 3 4
[zxc@study CppNetworkProgramming]$ ls /proc/2945/fd
0 1 2 3 4
[zxc@study CppNetworkProgramming]$ ps -ef|grep demo7
zxc 2774 2717 0 23:53 pts/0 00:00:00 ./demo7 5005
zxc 2945 2774 0 23:54 pts/0 00:00:00 ./demo7 5005
zxc 2977 2774 0 23:55 pts/0 00:00:00 ./demo7 5005
zxc 2979 2822 0 23:55 pts/2 00:00:00 grep --color=auto demo7
[zxc@study CppNetworkProgramming]$ ls /proc/2774/fd
0 1 2 3 4 5
[zxc@study CppNetworkProgramming]$ ls /proc/2945/fd
0 1 2 3 4
[zxc@study CppNetworkProgramming]$ ls /proc/2977/fd
0 1 2 3 4 5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// 终端	部分
// 服务端
[zxc@study CppNetworkProgramming]$ ./demo7 5005
客户端已连接(141.19.0.0)
接收:这是第1个报文,编号1
发送:ok
客户端已连接(0.0.0.0)
接收:这是第1个报文,编号1
发送:ok
接收:这是第2个报文,编号2
发送:ok
接收:这是第2个报文,编号2
发送:ok
接收:这是第3个报文,编号3
发送:ok
接收:这是第3个报文,编号3
发送:ok
接收:这是第4个报文,编号4
发送:ok
接收:这是第4个报文,编号4
发送:ok
接收:这是第5个报文,编号5
发送:ok
接收:这是第5个报文,编号5
发送:ok
接收:这是第6个报文,编号6
发送:ok
接收:这是第6个报文,编号6
发送:ok
接收:这是第7个报文,编号7
发送:ok
接收:这是第7个报文,编号7
发送:ok
^C父进程退出,sig=2
子进程40256退出,sig=15
子进程40254退出,sig=15

// 1号客户端
[zxc@study CppNetworkProgramming]$ ./demo5 192.168.112.131 5005
发送:这是第1个报文,编号1
接收:ok
发送:这是第2个报文,编号2
接收:ok
发送:这是第3个报文,编号3
接收:ok
发送:这是第4个报文,编号4
接收:ok
发送:这是第5个报文,编号5
接收:ok
发送:这是第6个报文,编号6
接收:ok
发送:这是第7个报文,编号7
接收:ok
发送:这是第8个报文,编号8
recv(): Success

// 2号客户端
[zxc@study CppNetworkProgramming]$ ./demo5 192.168.112.131 5005
发送:这是第1个报文,编号1
接收:ok
发送:这是第2个报文,编号2
接收:ok
发送:这是第3个报文,编号3
接收:ok
发送:这是第4个报文,编号4
接收:ok
发送:这是第5个报文,编号5
接收:ok
发送:这是第6个报文,编号6
接收:ok
发送:这是第7个报文,编号7
接收:ok
发送:这是第8个报文,编号8
recv(): Success

实现文件传输功能

采用 socket 通讯,客户端把指定文件传输给服务端(支持文本文件和二进制文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
/*
* 程序名:demo8.cpp,此程序用于演示文件传输的客户端
*/
#include <iostream>
#include <fstream>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
using namespace std;

class ctcpclient // TCP 通讯的客户端类
{
private:
int m_clientfd; // 客户端的 socket,-1表示未连接或连接已断开;>=0表示有效的 socket
string m_ip; // 服务端的 IP /域名
unsigned short m_port; // 通讯端口

public:
ctcpclient() : m_clientfd(-1) {}

// 向服务端发起连接请求,成功返回 true,失败返回 false
bool connect(const string &in_ip, const unsigned short in_port)
{
if(m_clientfd != -1) return false; // 如果 socket 已连接,直接返回失败

// 把服务端的 IP 和端口保存到成员变量中
m_ip = in_ip;
m_port = in_port;

// 第1步:创建客户端的 socket
if((m_clientfd = socket(AF_INET, SOCK_STREAM, 0)) == -1)
{
return false;
}

// 第2步:向服务器发起连接请求
struct sockaddr_in servaddr; // 用于存放协议、端口和 IP 地址的结构体
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET; // 1)协议族,固定填 AF_INET
servaddr.sin_port = htons(m_port); // 2)指定服务端的通信端口

struct hostent* h; // 用于存放服务器 IP 地址(大端序)的结构体的指针
if((h = gethostbyname(m_ip.c_str())) == nullptr) // 把域名/主机名/字符串格式的 IP 转换成结构体
{
::close(m_clientfd); // 全局 close 库函数
m_clientfd = -1;
return false;
}
memcpy(&servaddr.sin_addr, h->h_addr, h->h_length); // 3)指定服务端的 IP(大端序)

// 向服务端发起连接请求
if(::connect(m_clientfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) == -1) // 全局 connect 库函数
{
::close(m_clientfd); // 全局 close 库函数
m_clientfd = -1;
return false;
}

return true;
}

// 向服务端发送报文(字符串),成功返回 true,失败返回 false
bool send(const string& buffer) // buffer 不要用 const char *
{
if(m_clientfd == -1) return false; // 如果 socket 的状态是未连接,直接返回失败

if((::send(m_clientfd, buffer.data(), buffer.size(), 0)) <= 0) return false; // 全局 send 库函数

return true;
}

// 向服务端发送报文(二进制数据),成功返回 true,失败返回 false
bool send(void* buffer, const size_t size)
{
if(m_clientfd == -1) return false; // 如果 socket 的状态是未连接,直接返回失败

if((::send(m_clientfd, buffer, size, 0)) <= 0) return false; // 全局 send 库函数

return true;
}

// 接收服务端的报文,成功返回 true,失败返回 false
// buffer-存放接收到的报文的内容,maxlen-本次接收报文的最大长度
bool recv(string& buffer, const size_t maxlen)
{ // 如果直接操作 string 对象的内存,必须保证:1)不能越界 2)操作后手动设置数据的大小
buffer.clear(); // 清空容器
buffer.resize(maxlen); // 设置容器的大小为 maxlen
int readn = ::recv(m_clientfd, &buffer[0], buffer.size(), 0); // 直接操作 buffer 的内存,全局 recv 库函数
if(readn <= 0)
{
buffer.clear();
return false;
}
buffer.resize(readn); // 重置 buffer 的实际大小

return true;
}

// 断开与服务端的连接
bool close()
{
if(m_clientfd == -1) return false; // 如果 socket 的状态是未连接,直接返回失败

::close(m_clientfd); // 全局 close 库函数
m_clientfd = -1;
return true;
}

// 向服务端发送文件内容
bool sendfile(const string& filename, const size_t filesize)
{
// 以二进制的方式打开文件
ifstream fin(filename, ios::binary);
if(fin.is_open() == false)
{
cout << "打开文件" << filename << "失败\n";
return false;
}

int onread = 0; // 每次调用 fin.read() 时打算读取的字节数
int totalbytes = 0; // 从文件中已读取的字节总数
char buffer[4096]; // 存放读取数据的 buffer

while(true)
{
memset(buffer, 0, sizeof(buffer));

// 计算本次应该读取的字节数,如果剩余的数据超过4096字节,就读4096字节
if(filesize - totalbytes > 4096) onread = 4096;
else onread = filesize - totalbytes;

// 从文件中读取数据
fin.read(buffer, onread);

// 把读取到的数据发送给对端
if(send(buffer, onread) == false) return false;

// 计算文件已读取的字节总数,如果文件已读完,跳出循环
totalbytes = totalbytes + onread;

if(totalbytes == filesize) break;
}
return true;
}
~ctcpclient()
{
close();
}
};

int main(int argc, char* argv[])
{
if(argc != 5)
{
cout << "Using: ./demo8 服务端的 IP 服务端的端口 文件名 文件大小\n";
cout << "Example: ./demo8 192.168.112.131 5005 aaa.txt 2424\n\n";
return -1;
}

ctcpclient tcpclient;
if(tcpclient.connect(argv[1], atoi(argv[2])) == false) // 向服务端发起连接请求
{
perror("connect()");
return -1;
}

// 以下是发送文件的流程
// 1)把待传输文件名和文件的大小告诉服务端
// 定义文件信息的结构体
struct st_fileinfo{
char filename[256]; // 文件名
int filesize; // 文件大小
}fileinfo;
memset(&fileinfo, 0, sizeof(fileinfo));
strcpy(fileinfo.filename, argv[3]); // 文件名
fileinfo.filesize = atoi(argv[4]); // 文件大小
// 把文件信息的结构体发送给服务端
if(tcpclient.send(&fileinfo, sizeof(fileinfo)) == false)
{
perror("send");
return -1;
}
cout << "发送文件信息的结构体" << fileinfo.filename << "(" << fileinfo.filesize << ")" << endl;

// 2)等待服务端的确认报文(文件名和文件的大小的确认)
string buffer;
if(tcpclient.recv(buffer, 2) == false)
{
perror("recv()");
return -1;
}
if(buffer != "ok")
{
cout << "服务端没有回复ok\n";
return -1;
}
// 3)发送文件内容
if(tcpclient.sendfile(fileinfo.filename, fileinfo.filesize) == false)
{
perror("sendfile()");
return -1;
}

// 4)等待服务端的确认报文(服务端已接收完文件)
if (tcpclient.recv(buffer,2)==false) { perror("recv()"); return -1; }
if (buffer!="ok")
{
cout << "发送文件内容失败\n";
return -1;
}
cout << "发送文件内容成功\n";
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
/*
* 程序名:demo9.cpp,此程序用于演示文件传输的服务端
*/
#include <iostream>
#include <fstream>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <signal.h>
#include <netdb.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
using namespace std;

class ctcpserver // TCP通讯的服务端类
{
private:
int m_listenfd; // 监听的 socket, -1表示未初始化
int m_clientfd; // 客户端连上来的 socket,-1表示客户端未连接
string m_clientip; // 客户端的 IP
unsigned short m_port; // 服务端用于通讯的端口
public:
ctcpserver() : m_listenfd(-1), m_clientfd(-1) {}

// 初始化服务端用于监听的 socket
bool initserver(const unsigned short in_port)
{
// 第1步:创建服务端的 socket
if((m_listenfd = socket(AF_INET, SOCK_STREAM, 0)) == -1) return false;

m_port = in_port;

// 第2步:把服务端用于通信的 IP 和端口绑定到 socket 上
struct sockaddr_in servaddr; // 用于存放协议、端口和 IP 地址的结构体
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET; // 1)协议族,固定填 AF_INET
servaddr.sin_port = htons(m_port); // 2)指定服务端的通信端口
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 3)如果操纵系统有多个 IP,全部的 IP 都可以用于通讯

// 绑定服务端的 IP 和端口(为 socket 分配 IP 和端口)
if(bind(m_listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) == -1)
{
close(m_listenfd);
m_listenfd = -1;
return false;
}

// 第3步:把 socket 设置为可连接(监听)的状态
if(listen(m_listenfd, 5) == -1)
{
close(m_listenfd);
m_listenfd = -1;
return false;
}

return true;
}

// 受理客户端的连接(从已连接的客户端中取出一个客户端),如果没有已连接的客户端,accept() 函数将阻塞等待
bool accept()
{
struct sockaddr_in caddr; // 客户端的地址信息
socklen_t addrlen = sizeof(caddr); // struct sockaddr_in 的大小
if((m_clientfd = ::accept(m_listenfd, (struct sockaddr*)&caddr, &addrlen)) == -1) return false; // 全局 accept 库函数

m_clientip = inet_ntoa(caddr.sin_addr); // 客户端的地址从大端序转换成字符串

return true;
}

// 获取客户端的 IP(字符串格式)
const string& clientip() const
{
return m_clientip;
}

// 向对端发送报文,成功返回 true,失败返回 false
bool send(const string& buffer)
{
if(m_clientfd == -1) return false;

if((::send(m_clientfd, buffer.data(), buffer.size(), 0)) <= 0) return false; // 全局 send 库函数

return true;
}

// 接收对端的报文,成功返回 true,失败返回 false
// buffer-存放接收到的报文的内容,maxlen-本次接收报文的最大长度
bool recv(string& buffer, const size_t maxlen)
{
buffer.clear(); // 清空容器
buffer.resize(maxlen); // 设置容器的大小为 maxlen
int readn = ::recv(m_clientfd, &buffer[0], buffer.size(), 0); // 直接操作 buffer 的内存,全局 recv 库函数
if(readn <= 0)
{
buffer.clear();
return false;
}
buffer.resize(readn); // 重置 buffer 的实际大小

return true;
}

// 接收客户端的报文(二进制数据),成功返回 true,失败返回 false
// buffer-存放接收到的报文的内容,size-本次接收报文的最大长度
bool recv(void* buffer, const size_t size)
{
if(::recv(m_clientfd, buffer, size, 0) <= 0) // 全局 recv 库函数
{
return false;
}

return true;
}

// 关闭监听的 socket
bool closelisten()
{
if(m_listenfd == -1) return false;

close(m_listenfd);
m_listenfd = -1;
return true;
}

// 关闭客户端连上来的 socket
bool closeclient()
{
if(m_clientfd == -1) return false;

close(m_clientfd);
m_clientfd = -1;
return true;
}

// 接收文件内容
bool recvfile(const string& filename, const size_t filesize)
{
ofstream fout;
fout.open(filename, ios::binary);
if (fout.is_open() == false)
{
cout << "打开文件"<< filename << "失败\n";
return false;
}

int totalbytes=0; // 已接收文件的总字节数
int onread=0; // 本次打算接收的字节数
char buffer[4096]; // 接收文件内容的缓冲区

while (true)
{
// 计算本次应该接收的字节数
if(filesize - totalbytes > 4096) onread = 4096;
else onread = filesize - totalbytes;

// 接收文件内容
if(recv(buffer ,onread) == false) return false;

// 把接收到的内容写入文件
fout.write(buffer, onread);

// 计算已接收文件的总字节数,如果文件接收完,跳出循环
totalbytes = totalbytes + onread;

if(totalbytes == filesize) break;
}
return true;
}

~ctcpserver()
{
closelisten();
closeclient();
}
};

ctcpserver tcpserver;

void FathEXIT(int sig); // 父进程的信号处理函数
void ChldEXIT(int sig); // 子进程的信号处理函数

int main(int argc, char* argv[])
{
if(argc != 3)
{
cout << "Using: ./demo9 通讯端口 文件存放的目录\n";
cout << "Example: ./demo9 5005 /tmp\n\n";
cout << "注意:运行服务端程序的 Linux 系统的防火墙必须要开通5005端口\n";
cout << " 如果是云服务器,还要开通云平台的访问策略\n\n";
return -1;
}

// 忽略全部的信号,不希望被打扰,顺便解决了僵尸进程的问题
for(int ii = 1; ii <= 64; ii++) signal(ii, SIG_IGN);

// 设置信号,在 shell 状态下可用 "kill 进程号" 或 "Ctrl+c" 正常终止进程
// 但请不要用 "kill -9 +进程号" 强行终止
signal(SIGTERM, FathEXIT);
signal(SIGINT, FathEXIT); // SIGTERM 15 SIGINT 2

if(tcpserver.initserver(atoi(argv[1])) == false) // 初始化服务端用于监听的 socket
{
perror("initserver()");
return -1;
}

while(true)
{
// 受理客户端的连接(从已连接的客户端中取出一个客户端),如果没有已连接的客户端,accept() 函数将阻塞等待
if(tcpserver.accept() == false)
{
perror("accept()");
return -1;
}

int pid = fork();
if(pid == -1) // 系统资源不足
{
perror("fork()");
return -1;
}
if(pid > 0) // 父进程
{
tcpserver.closeclient(); // 父进程关闭客户端连接的 socket
continue; // 父进程返回到循环开始的位置,继续受理客户端的连接
}

tcpserver.closelisten(); // 子进程关闭监听的 socket

// 子进程需要重新设置信号
signal(SIGTERM, ChldEXIT); // 子进程的退出函数与父进程不一样
signal(SIGINT, SIG_IGN); // 子进程不需要捕获 SIGINT 信号

// 子进程负责与客户端进行通讯
cout << "客户端已连接(" <<tcpserver.clientip() << ")\n";

// 以下是接收文件的流程
// 1)接收文件名和文件大小信息
// 定义文件信息的结构体
struct st_fileinfo{
char filename[256]; // 文件名
int filesize; // 文件大小
}fileinfo;
memset(&fileinfo, 0, sizeof(fileinfo));
// 用结构体存放接收报文的内容
if(tcpserver.recv(&fileinfo, sizeof(fileinfo)) == false)
{
perror("recv()");
return -1;
}
cout << "文件信息结构体" << fileinfo.filename << "(" << fileinfo.filesize << ")" << endl;

// 2)给客户端回复确认报文,表示客户端可以发送文件了
if(tcpserver.send("ok") == false)
{
perror("send");
break;
}

// 3)接收文件内容 string char * + const char * + char * string 才能相加
if(tcpserver.recvfile(string(argv[2]) + "/" + fileinfo.filename, fileinfo.filesize) == false)
{
cout << "接收文件内容失败\n";
return -1;
}
cout << "接收文件内容成功\n";

// 4)给客户端回复确认报文,表示文件已接收成功
tcpserver.send("ok");

return 0; // 子进程一定要退出,否则又会回到 accept() 函数的位置
}
}

// 父进程的信号处理函数
void FathEXIT(int sig)
{
// 以下代码是为了防止信号处理函数在执行的过程中再次被信号中断
signal(SIGINT, SIG_IGN);
signal(SIGTERM, SIG_IGN);

cout << "父进程退出,sig=" << sig << endl;

kill(0, SIGTERM); // 向全部的子进程发送15的信号,通知它们退出

// 在这里增加释放资源的代码(全局的资源)
tcpserver.closelisten(); // 父进程关闭监听的 socket

exit(0);
}

// 子进程的信号处理函数
void ChldEXIT(int sig)
{
// 以下代码是为了防止信号处理函数在执行的过程中再次被信号中断
signal(SIGINT, SIG_IGN);
signal(SIGTERM, SIG_IGN);

cout << "子进程" << getpid() << "退出,sig=" << sig << endl;

// 在这里增加释放资源的代码(只释放子进程的资源)
tcpserver.closeclient(); // 子进程关闭客户端连上来的 socket

exit(0);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 终端
// 服务端
[zxc@study CppNetworkProgramming]$ dd if=/dev/zero of=aaa.txt bs=2424 count=1
记录了1+0 的读入
记录了1+0 的写出
2424字节(2.4 kB)已复制,0.00107924 秒,2.2 MB/秒
[zxc@study CppNetworkProgramming]$ ll aaa.txt
-rw-rw-r--. 1 zxc zxc 2424 10月 13 12:35 aaa.txt
[zxc@study CppNetworkProgramming]$ ./demo9 5005
Using: ./demo9 通讯端口 文件存放的目录
Example: ./demo9 5005 /tmp

注意:运行服务端程序的 Linux 系统的防火墙必须要开通5005端口
如果是云服务器,还要开通云平台的访问策略

[zxc@study CppNetworkProgramming]$ ./demo9 5005 /tmp
客户端已连接(192.168.112.131)
文件信息结构体aaa.txt(2424)
接收文件内容成功
^C父进程退出,sig=2

// 客户端
[zxc@study CppNetworkProgramming]$ ./demo8 192.168.112.131 5005 aaa.txt 2424
发送文件信息的结构体aaa.txt(2424)
发送文件内容成功
[zxc@study CppNetworkProgramming]$ ll /tmp/aaa.txt
-rw-rw-r--. 1 zxc zxc 2424 10月 13 12:36 /tmp/aaa.txt

三次握手与四次挥手

TCP 是面向连接的、可靠的协议,建立 TCP 连接需要三次对话(三次握手),拆除 TCP 连接需要四次对话(四次握/挥手)

三次握手

服务端调用 listen() 函数后进入监听(等待连接)状态,这时候,客户端就可以调用 connect() 函数发起 TCP 连接请求
客户端调用 connect() 函数的时候会触发三次握手
三次握手完成后,客户端与服务端将建立一个双向的传输通道

image-20251008102303003
  1. 客户端的 socket 也有端口号,对程序员来说,不必关心客户端 socket 的端口号,因为系统随机分配(socket 通讯中的地址包括 ip 和端口号,但是,习惯中的地址仅指 ip 地址)
  2. 服务端的 bind() 函数,普通用户只能使用1024以上的端口,root 用户可以使用任意端口
  3. listen() 函数的第二个参数+1为已连接队列(ESTABLISHED状态,三次握手已完成但是没有被 accept() 的 socket,只存在于服务端)的大小(在高并发的服务程序中,该参数应该调大一些)
  4. SYN_RECV 状态的连接也称为半连接
  5. CLOSED 是假想状态,实际上不存在

四次挥手(握手)

断开一个 TCP 连接时,需要客户端和服务端相互总共发送四个包以确认连接的断开
在 socket 编程中,这一过程由客户端或服务端任一方执行 close() 函数触发

image-20251008102943495
  1. 主动断开的端在四次挥手后,socket 的状态为 TIME_WAIT,该状态将持续 2MSL(30秒/1分钟/2分钟)
    MSL(Maximum Segment Lifetime)报文在网络上存在的最长时间,超过这个时间报文将被丢弃

  2. 如果是客户端主动断开,TIME_WAIT 状态的 socket 几乎不会造成危害
    1)客户端程序的 socket 很少,服务端程序很多(成千上万)
    2)客户端的端口是随机分配的,不存在重用的问题

  3. 如果是服务端主动断开,有两方面危害
    1)socket 没有立即释放
    2)端口号只能在 2MSL 后才能重用
    在服务端程序中,用 setsockopt() 函数设置 socket 的属性(一定要放在 bind() 之前)

    1
    2
    int opt = 1;
    setsockopt(m_listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

TCP 缓存

系统为每个 socket 创建了发送缓冲区和接收缓冲区
应用程序调用 send()/write() 函数发送数据的时候,内核把数据从应用进程拷贝到 socket 的发送缓冲区中
应用程序调用 recv()/read() 函数接收数据的时候,内核把数据从 socket 的接收缓冲区拷贝到应用进程中
发送数据即把数据放入发送缓冲区
接收数据即从接收缓冲区中去数据

image-20251008103943419

查看 socket 缓存的大小

1
2
3
4
5
6
7
8
int bufsize = 0;
socklen_t optlen = sizeof(bufsize);

getsockopt(sockfd, SOL_SOCKET, SO_SNDBUF, &bufsize, &optlen); // 获取发送缓冲区的大小
cout << "send bufsize=" << bufsize << endl;

getsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &bufsize, &optlen); // 获取接收缓冲区的大小
cout << "recv bufsize=" << bufsize << endl;
  1. send() 函数有可能会阻塞吗
    如果自己的发送缓冲区和对端的接收缓冲区都满了,会阻塞

  2. 向 socket 中写入数据后,如果关闭了 socket,对端还能接收到数据吗
    对端能够接收到数据

  3. Nagle 算法
    在 TCP 协议中,无论发送多少数据,都要在数据前面加上协议头,同时,对方收到数据后,也需要回复 ACK 表示确认
    为了尽可能的利用网络带宽,TCP 希望每次都能够以 MSS(Maximum Segment Size,最大报文长度)的数据块来发送数据
    Nagle 算法就是为了尽可能发送大块的数据,避免网络中充斥着小数据块
    Nagle 算法的定义是:任意时刻,最多只能有一个未被确认的小段,小段是指小于 MSS 的数据块,未被确认是指一个数据块发送出去后,没有收到对端回复的 ACK
    例如,发送端调用 send() 函数将一个 int 型数据(称之为 A 数据块)写入到 socket 中,A 数据块会被马上发送到接收端,接着,发送端又调用 send() 函数写入一个 int 型数据(称之为 B 数据块),这时候,A 块的 ACK 没有返回(已经存在了一个未被确认的小段),所以 B 块不会立即被发送,而是等 A 块的 ACK 返回之后(大概40ms)才发送
    TCP协议中不仅仅有 Nagle 算法,还有一个 ACK 延迟机制:当接收端收到数据之后,并不会马上向发送端回复 ACK,而是延迟40ms后再回复,它希望在40ms内接收端会向发送端回复应答数据,这样 ACK 就可以和应答数据一起发送,把 ACK 捎带过去
    如果 TCP 连接的一端启用了 Nagle 算法,另一端启用了 ACK 延时机制,而发送的数据包又比较小,则可能出现这样的情况:发送端在等待上一个包的 ACK,而接收端正好延迟了此 ACK,那么这个正要被发送的包就会延迟 40ms
    解决方案
    开启 TCP_NODELAY 选项,这个选项的作用就是禁用 Nagle 算法

    1
    2
    3
    4
    #include <netinet/tcp.h>		// 注意,要包含这个头文件

    int opt = 1;
    setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, &opt, sizeof(opt));

    对时效要求很高的系统,例如联机游戏、证券交易,一般会禁用 Nagle 算法

I/O 多路复用

  • 用一个进/线程处理多个 TCP 连接,减少系统开销
  • 三种模型:select(1024)、poll(数千)和 epoll(百万)

网络通讯-写事件

发送缓冲区没有满,可以写入数据(可以向对端发送报文)

网络通讯-读事件

  1. 已连接队列中有已经准备好的 socket(有新的客户端连上来)
  2. 接收缓存中有数据可以读(对端发送的报文已到达)
  3. TCP 连接已断开(对端调用 close() 函数关闭了连接)

select 模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* According to POSIX.1-2001 */
#include <sys/select.h>

/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

void FD_CLR(int fd, fd_set *set); // 把 socket 从 bitmap 中删除
int FD_ISSET(int fd, fd_set *set); // 判断 socket 是否在 bitmap 中
void FD_SET(int fd, fd_set *set); // 把 socket 加入到集合中
void FD_ZERO(fd_set *set); // 初始化位图,全部置为0

select 模型-性能测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
/*
* 程序名:tcpselect.cpp,此程序用于演示采用 select 模型实现网络通讯的服务端
*
*/
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <sys/fcntl.h>

// 初始化服务端的监听端口
int initserver(int port);

int main(int argc, char* argv[])
{
if(argc != 2)
{
printf("usage: ./tcpselect port\n");
return -1;
}

// 初始化服务端用于监听的 socket
int listensock = initserver(atoi(argv[1]));
printf("listensock=%d\n", listensock);

if(listensock < 0)
{
printf("initserver() failed\n");
return -1;
}

fd_set readfds; // 需要监视读事件的 socket 的集合,大小为16字节(1024位)的 bitmap
FD_ZERO(&readfds); // 初始化 readfds,把 bitmap 的每一位都置为0
FD_SET(listensock, &readfds); // 把服务端用于监听的 socket 加入 readfds

int maxfd = listensock; // readfds 中 socket 的最大值

while(true)
{
// 用于表示超时时间的结构体
struct timeval timeout;
timeout.tv_sec = 10; // 秒
timeout.tv_usec = 0; // 微秒

// 在 select() 函数中,会修改 bitmap,所以,要把 readfds 复制一份给 tmpfds,再把 tmpfds 传给 select()
fd_set tmpfds = readfds;

// select() 等待事件的发生(监视哪些 socket 发生了事件)
int infds = select(maxfd + 1, &tmpfds, NULL, NULL, &timeout);

// 如果 infds < 0,表示调用 select() 失败
if(infds < 0)
{
perror("select() failed");
break;
}

// 如果 infds == 0,表示 select() 超时
if(infds == 0)
{
printf("select() timeout\n");
continue;
}

// 如果 infds > 0,表示有事件发生,infds 存放了已发生事件的个数
for(int eventfd = 0; eventfd <= maxfd; eventfd++)
{
// 如果 eventfd 在 bitmap 中的标志为0,表示它没有事件,continue
if(FD_ISSET(eventfd, &tmpfds) == 0) continue;

// 如果发生事件的是 listensock,表示已连接队列中有已经准备好的 socket(有新的客户端连上来了)
if(eventfd == listensock)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int clientsock = accept(listensock, (struct sockaddr*)&client, &len);
if(clientsock < 0)
{
perror("accept() failed");
continue;
}

printf("accept client(socket=%d) ok\n", clientsock);

// 把 bitmap 中新连上来的客户端的标志位置为1
FD_SET(clientsock, &readfds);

// 更新 maxfd 的值
if(maxfd < clientsock) maxfd = clientsock;
}else
{
// 如果是客户端连接的 socket 有事件,表示接收缓存中有数据可以读(对端发送的报文已到达),或者有客户端已断开连接
char buffer[1024]; // 存放从客户端读取的数据
memset(buffer, 0, sizeof(buffer));
if(recv(eventfd, buffer, sizeof(buffer), 0) <= 0)
{
// 如果客户端的连接已断开
printf("client(eventfd=%d) disconnected\n", eventfd);

close(eventfd); // 关闭客户端的 socket

FD_CLR(eventfd, &readfds); // 把 bitmap 中已关闭客户端的标志位清空

if(eventfd == maxfd) // 重新计算 maxfd 的值,注意,只要当 eventfd == maxfd 时才需要计算
{
for(int ii = maxfd; ii > 0; ii--) // 从后面往前找
{
if(FD_ISSET(ii, &readfds))
{
maxfd = ii;
break;
}
}
}
}else
{
// 如果客户端有报文发过来
printf("recv(eventfd=%d):%s\n", eventfd, buffer);

// 把接收到的报文内容原封不动的发回去
send(eventfd, buffer, strlen(buffer), 0);
}
}
}
}

return 0;
}

// 初始化服务端的监听端口
int initserver(int port)
{
int sock = socket(AF_INET, SOCK_STREAM, 0);
if(sock < 0)
{
perror("socket() failed");
return -1;
}

int opt = 1;
unsigned int len = sizeof(opt);
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, len);

struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(port);

if(bind(sock, (struct sockaddr*)&servaddr, sizeof(servaddr)) < 0)
{
perror("bind() failed");
close(sock);
return -1;
}

if(listen(sock, 5) != 0)
{
perror("listen() failed");
close(sock);
return -1;
}

return sock;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
/*
* 程序名:client.cpp,此程序用于演示采用 select 模型实现网络通讯的客户端
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <arpa/inet.h>

int main(int argc, char* argv[])
{
if(argc != 3)
{
printf("usage: ./client ip port\n");
return -1;
}

int sockfd;
struct sockaddr_in servaddr;
char buf[1024];

if((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
{
printf("socket() failed\n");
return -1;
}

memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(atoi(argv[2]));
servaddr.sin_addr.s_addr = inet_addr(argv[1]);

if(connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) != 0)
{
printf("connect(%s:%s) failed\n", argv[1], argv[2]);
close(sockfd);
return -1;
}

printf("connect ok\n");

for(int ii = 0; ii < 1000000; ii++)
{
// 从命令行输入内容
memset(buf, 0, sizeof(buf));
printf("please input:");
scanf("%s", buf);

if(send(sockfd, buf, strlen(buf), 0) <= 0)
{
printf("write() failed\n");
close(sockfd);
return -1;
}

memset(buf, 0, sizeof(buf));
if(recv(sockfd, buf, sizeof(buf), 0) <= 0)
{
printf("read() failed\n");
close(sockfd);
return -1;
}

printf("recv:%s\n", buf);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 终端
// 服务端
[zxc@study CppNetworkProgramming]$ g++ -std=c++11 -g -o tcpselect tcpselect.cpp
[zxc@study CppNetworkProgramming]$ ./tcpselect 5008
listensock=3
select() timeout // 每隔10s超时
accept client(socket=4) ok
recv(eventfd=4):aaaaa
select() timeout
select() timeout
accept client(socket=5) ok
recv(eventfd=5):bbbbbb
client(eventfd=4) disconnected
client(eventfd=5) disconnected


// 1号客户端
[zxc@study CppNetworkProgramming]$ g++ -std=c++11 -g -o client client.cpp
[zxc@study CppNetworkProgramming]$ ./client 192.168.112.132 5008
connect ok
please input:aaaaa
recv:aaaaa
please input:^C

// 2号客户端
[zxc@study CppNetworkProgramming]$ ./client 192.168.112.132 5008
connect ok
please input:bbbbbb
recv:bbbbbb
please input:^C

select 模型-写事件

  • 如果 TCP 的发送缓冲区没有满,那么,socket 连接是可写的
  • 一般来说,发送缓冲区不容易被填满
  • 如果发送的数据量太大,或网络带宽不够,发送缓冲区有填满的可能
  • select 监视写事件,如果发送缓冲区没有满,select 函数会立即返回

select 模型-水平触发

  • select() 监视的 socket 如果发生了事件,select() 会返回(通知应用程序处理事件)
  • 如果事件没有被处理,再次调用 select() 的时候会立即再通知

select 模型-存在的问题

  • 采用轮询方式扫描 bitmap,性能会随着 socket 数量增多而下降
  • select() 会修改 bitmap,每次调用 select() 前,需要拷贝一份 bitmap,程序运行在用户态,网络通信在内核,调用 select() 的时候,要把 bitmap 从用户态拷贝一份到内核态,bitmap 总共需要拷贝两次
  • bitmap 的大小(单个进/线程打开的 socket 数量)由 FD_SETSIZE 宏设置,默认是1024个,可以修改,但是效率将更低

poll 模型

1
2
3
4
5
6
7
8
9
10
11
12
13
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};

// poll 事件
POLLIN
There is data to read.
POLLPRI
There is urgent data to read (e.g., out-of-band data on TCP socket; pseudoterminal master in packet mode has seen state change in slave).
POLLOUT
Writing now will not block.

poll模型-性能测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
/*
* 程序名:tcppoll.cpp,此程序用于演示采用 poll 模型实现网络通讯的服务端
*
*/
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <poll.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <sys/fcntl.h>

// 初始化服务端的监听端口
int initserver(int port);

int main(int argc, char* argv[])
{
if(argc != 2)
{
printf("usage: ./tcppoll port\n");
return -1;
}

// 初始化服务端用于监听的 socket
int listensock = initserver(atoi(argv[1]));
printf("listensock=%d\n", listensock);

if(listensock < 0)
{
printf("initserver() failed\n");
return -1;
}

pollfd fds[2048]; // fds 存放需要监视的 socket

// 初始化数组,把全部的 socket 设置为-1,如果数组中的 socket 的值为-1,那么,poll 将忽略它
for(int ii = 0; ii < 2048; ii++) fds[ii].fd = -1;

// 打算让 poll 监视 listensock 读事件
fds[listensock].fd = listensock;
fds[listensock].events = POLLIN; // POLLIN 表示读事件,POLLOUT 表示写事件
// fds[listensock].events = POLLIN|POLLOUT;

int maxfd = listensock; // fds 数组中需要监视的 socket 的实际大小

while(true)
{
// 调用 poll() 等待事件的发生(监视哪些 socket 发生了事件)
int infds = poll(fds, maxfd+1, 10000); // 超时时间为10秒

// 如果 infds < 0,表示调用 poll() 失败
if(infds < 0)
{
perror("poll() failed");
break;
}

// 如果 infds == 0,表示 poll() 超时
if(infds == 0)
{
printf("poll() timeout\n");
continue;
}

// 如果 infds > 0,表示有事件发生,infds 存放了已发生事件的个数
for(int eventfd = 0; eventfd <= maxfd; eventfd++)
{
// 如果 fd 为负,忽略它
if(fds[eventfd].fd < 0) continue;

// 如果没有读事件,continue
if((fds[eventfd].revents&POLLIN) == 0) continue;

// 如果发生事件的是 listensock,表示已连接队列中有已经准备好的 socket(有新的客户端连上来了)
if(eventfd == listensock)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int clientsock = accept(listensock, (struct sockaddr*)&client, &len);
if(clientsock < 0)
{
perror("accept() failed");
continue;
}

printf("accept client(socket=%d) ok\n", clientsock);

// 修改 fds 数组中 clientsock 位置的元素
fds[clientsock].fd = clientsock;
fds[clientsock].events = POLLIN;

// 更新 maxfd 的值
if(maxfd < clientsock) maxfd = clientsock;
}else
{
// 如果是客户端连接的 socket 有事件,表示有报文发过来了或者连接已断开
char buffer[1024]; // 存放从客户端读取的数据
memset(buffer, 0, sizeof(buffer));
if(recv(eventfd, buffer, sizeof(buffer), 0) <= 0)
{
// 如果客户端的连接已断开
printf("client(eventfd=%d) disconnected\n", eventfd);

close(eventfd); // 关闭客户端的 socket
fds[eventfd].fd = -1; // 修改 fds 数组中 clientsock 位置的元素,置为-1,poll将忽略该元素

if(eventfd == maxfd) // 重新计算 maxfd 的值,注意,只要当 eventfd == maxfd 时才需要计算
{
for(int ii = maxfd; ii > 0; ii--) // 从后面往前找
{
if(fds[ii].fd != -1)
{
maxfd = ii;
break;
}
}
}
}else
{
// 如果客户端有报文发过来
printf("recv(eventfd=%d):%s\n", eventfd, buffer);

// 把接收到的报文内容原封不动的发回去
send(eventfd, buffer, strlen(buffer), 0);
}
}
}
}

return 0;
}

// 初始化服务端的监听端口
int initserver(int port)
{
int sock = socket(AF_INET, SOCK_STREAM, 0);
if(sock < 0)
{
perror("socket() failed");
return -1;
}

int opt = 1;
unsigned int len = sizeof(opt);
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, len);

struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(port);

if(bind(sock, (struct sockaddr*)&servaddr, sizeof(servaddr)) < 0)
{
perror("bind() failed");
close(sock);
return -1;
}

if(listen(sock, 5) != 0)
{
perror("listen() failed");
close(sock);
return -1;
}

return sock;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
/*
* 程序名:client.cpp,此程序用于演示采用 poll 模型实现网络通讯的客户端
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <arpa/inet.h>

int main(int argc, char* argv[])
{
if(argc != 3)
{
printf("usage: ./client ip port\n");
return -1;
}

int sockfd;
struct sockaddr_in servaddr;
char buf[1024];

if((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
{
printf("socket() failed\n");
return -1;
}

memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(atoi(argv[2]));
servaddr.sin_addr.s_addr = inet_addr(argv[1]);

if(connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) != 0)
{
printf("connect(%s:%s) failed\n", argv[1], argv[2]);
close(sockfd);
return -1;
}

printf("connect ok\n");

for(int ii = 0; ii < 1000000; ii++)
{
// 从命令行输入内容
memset(buf, 0, sizeof(buf));
printf("please input:");
scanf("%s", buf);

if(send(sockfd, buf, strlen(buf), 0) <= 0)
{
printf("write() failed\n");
close(sockfd);
return -1;
}

memset(buf, 0, sizeof(buf));
if(recv(sockfd, buf, sizeof(buf), 0) <= 0)
{
printf("read() failed\n");
close(sockfd);
return -1;
}

printf("recv:%s\n", buf);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 终端
// 服务端
[zxc@study CppNetworkProgramming]$ g++ -std=c++11 -g -o tcppoll tcppoll.cpp
[zxc@study CppNetworkProgramming]$ ./tcppoll 5008
listensock=3
poll() timeout
poll() timeout
accept client(socket=4) ok
recv(eventfd=4):bbb
accept client(socket=5) ok
recv(eventfd=5):cccc
client(eventfd=5) disconnected
client(eventfd=4) disconnected
^C


// 1号客户端
[zxc@study CppNetworkProgramming]$ ./client 192.168.112.132 5008
connect ok
please input:bbb
recv:bbb
please input:^C

// 2号客户端
[zxc@study CppNetworkProgramming]$ ./client 192.168.112.132 5008
connect ok
please input:cccc
recv:cccc
please input:^C

poll 模型-写事件

  • 如果 TCP 的发送缓冲区没有满,那么,socket 连接是可写的
  • 一般来说,发送缓冲区不容易被填满
  • 如果发送的数据量太大,或网络带宽不够,发送缓冲区有填满的可能

poll 模型-水平触发

  • poll() 监视的 socket 如果发生了事件,poll() 会返回(通知应用程序处理事件)
  • 如果事件没有被处理,再次调用 poll() 的时候会立即再通知

poll 模型-存在的问题

  • 在程序中,poll 的数据结构是数组,传入内核后转换成了链表
  • 每调用一次 select() 需要拷贝两次 bitmap,poll 拷贝一次结构体数组
  • poll 监视的连接数没有1024的限制,但是也是遍历的方法,监视的 socket 越多,效率越低

epoll 模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// creates  an  epoll(7) instance.  Since Linux 2.6.8, the size argument is ignored, but must be  greater  than  zero;  see  NOTES below
int epoll_create(int size);

typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;

struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};

epoll 模型-性能测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
/*
* 程序名:tcpepoll.cpp,此程序用于演示采用 epoll 模型实现网络通讯的服务端
*
*/
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <sys/fcntl.h>
#include <sys/epoll.h>

// 初始化服务端的监听端口
int initserver(int port);

int main(int argc, char* argv[])
{
if(argc != 2)
{
printf("usage: ./tcpepoll port\n");
return -1;
}

// 初始化服务端用于监听的 socket
int listensock = initserver(atoi(argv[1]));
printf("listensock=%d\n", listensock);

if(listensock < 0)
{
printf("initserver() failed\n");
return -1;
}

// 创建 epoll 句柄
int epollfd = epoll_create(1);

// 为服务端的 listensock 准备可读事件
epoll_event ev; // 声明事件的数据结构
ev.data.fd = listensock; // 指定事件的自定义数据,会随着 epoll_wait() 返回的事件一并返回
ev.events = EPOLLIN; // 打算让 epoll 监视 listensock 的读事件

epoll_ctl(epollfd, EPOLL_CTL_ADD, listensock, &ev); // 把需要监视的 socket 加入 epollfd 中

epoll_event evs[10]; // 存放 epoll 返回的事件

while(true)
{
// 等待监视的 socket 有事件发生
int infds = epoll_wait(epollfd, evs, 10, -1);

// 如果 infds < 0,表示调用 epoll_wait() 失败
if(infds < 0)
{
perror("epoll() failed");
break;
}

// 如果 infds == 0,表示 epoll_wait() 超时
if(infds == 0)
{
printf("epoll() timeout\n");
continue;
}

// 如果 infds > 0,表示有事件发生的 socket 的数量
for(int ii = 0; ii < infds; ii++) // 遍历 epoll 返回的数组 evs
{
// printf("socket=%d,events=%d\n", evs[ii].data.fd, evs[ii].events);

// 如果发生事件的是 listensock,表示已连接队列中有已经准备好的 socket(有新的客户端连上来了)
if(evs[ii].data.fd == listensock)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int clientsock = accept(listensock, (struct sockaddr*)&client, &len);

printf("accept client(socket=%d) ok\n", clientsock);

// 为新客户端准备可读事件,并添加到 epoll 中
ev.data.fd = clientsock;
ev.events = EPOLLIN;
epoll_ctl(epollfd, EPOLL_CTL_ADD, clientsock, &ev);
}else
{
// 如果是客户端连接的 socket 有事件,表示有报文发过来了或者连接已断开
char buffer[1024]; // 存放从客户端读取的数据
memset(buffer, 0, sizeof(buffer));
if(recv(evs[ii].data.fd, buffer, sizeof(buffer), 0) <= 0)
{
// 如果客户端的连接已断开
printf("client(eventfd=%d) disconnected\n", evs[ii].data.fd);

close(evs[ii].data.fd); // 关闭客户端的 socket
// 从 epollfd 中删除客户端的 socket,如果 socket 被关闭了,会自动从 epollfd 中删除,所以以下代码不必启用
// epoll_ctl(epollfd, EPOLL_CTL_DEL, evs[ii].data.fd, 0);
}else
{
// 如果客户端有报文发过来
printf("recv(eventfd=%d):%s\n", evs[ii].data.fd, buffer);

// 把接收到的报文内容原封不动的发回去
send(evs[ii].data.fd, buffer, strlen(buffer), 0);
}
}
}
}

return 0;
}

// 初始化服务端的监听端口
int initserver(int port)
{
int sock = socket(AF_INET, SOCK_STREAM, 0);
if(sock < 0)
{
perror("socket() failed");
return -1;
}

int opt = 1;
unsigned int len = sizeof(opt);
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, len);

struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(port);

if(bind(sock, (struct sockaddr*)&servaddr, sizeof(servaddr)) < 0)
{
perror("bind() failed");
close(sock);
return -1;
}

if(listen(sock, 5) != 0)
{
perror("listen() failed");
close(sock);
return -1;
}

return sock;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
/*
* 程序名:client.cpp,此程序用于演示采用 epoll 模型实现网络通讯的客户端
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <arpa/inet.h>

int main(int argc, char* argv[])
{
if(argc != 3)
{
printf("usage: ./client ip port\n");
return -1;
}

int sockfd;
struct sockaddr_in servaddr;
char buf[1024];

if((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
{
printf("socket() failed\n");
return -1;
}

memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(atoi(argv[2]));
servaddr.sin_addr.s_addr = inet_addr(argv[1]);

if(connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) != 0)
{
printf("connect(%s:%s) failed\n", argv[1], argv[2]);
close(sockfd);
return -1;
}

printf("connect ok\n");

for(int ii = 0; ii < 1000000; ii++)
{
// 从命令行输入内容
memset(buf, 0, sizeof(buf));
printf("please input:");
scanf("%s", buf);

if(send(sockfd, buf, strlen(buf), 0) <= 0)
{
printf("write() failed\n");
close(sockfd);
return -1;
}

memset(buf, 0, sizeof(buf));
if(recv(sockfd, buf, sizeof(buf), 0) <= 0)
{
printf("read() failed\n");
close(sockfd);
return -1;
}

printf("recv:%s\n", buf);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 终端
// 服务端
[zxc@study CppNetworkProgramming]$ g++ -std=c++11 -g -o tcpepoll tcpepoll.cpp
[zxc@study CppNetworkProgramming]$ ./tcpepoll 5008
listensock=3
accept client(socket=5) ok
recv(eventfd=5):aaa
accept client(socket=6) ok
recv(eventfd=6):bbbbb
client(eventfd=6) disconnected
client(eventfd=5) disconnected
accept client(socket=5) ok
recv(eventfd=5):ccc
^C

// 1号客户端
[zxc@study CppNetworkProgramming]$ ./client 192.168.112.132 5008
connect ok
please input:aaa
recv:aaa
please input:^C

// 2号客户端
[zxc@study CppNetworkProgramming]$ ./client 192.168.112.132 5008
connect ok
please input:bbbbb
recv:bbbbb
please input:^C

// 3号客户端
[zxc@study CppNetworkProgramming]$ ./client 192.168.112.132 5008
connect ok
please input:ccc
recv:ccc

阻塞&非阻塞的 I/O

  • 阻塞:在进/线程中,发起一个调用时,在调用返回之前,进/线程会被阻塞等待,等待中的进/线程让出CPU的使用权
  • 非阻塞:在进/线程中,发起一个调用时,会立即返回
  • 会阻塞的四个函数:connect()、accept()、send()、recv()
  • 在传统的网络服务端程序中(每连接每线/进程),采用阻塞 I/O
  • 在 I/O 复用的模型中,事件循环不能被阻塞在任何环节,所以应该采用非阻塞 I/O

非阻塞 I/O - connect()

  • 对非阻塞的 I/O 调用 connect() 函数,不管是否能连接成功,connect() 都会立即返回失败,errno == EINPROGRESS
  • 对非阻塞的 I/O 调用 connect() 函数,如果 socket 的状态是可写的,证明连接是成功的,否则是失败的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
/*
* 程序名:client1.cpp,网络通讯的客户端程序
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <poll.h>
#include <fcntl.h>

// 把 socket 设置成非阻塞
int setnonblocking(int fd)
{
int flags;

// 获取 fd 的状态
if((flags = fcntl(fd, F_GETFL, 0)) == -1) flags = 0;

return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main(int argc, char* argv[])
{
if(argc != 3)
{
printf("usage: ./client1 ip port\n");
return -1;
}

int sockfd;
struct sockaddr_in servaddr;
char buf[1024];

if((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
{
printf("socket() failed\n");
return -1;
}

setnonblocking(sockfd); // 把 sockfd 设置成非阻塞

memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(atoi(argv[2]));
servaddr.sin_addr.s_addr = inet_addr(argv[1]);

if(connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) != 0)
{
if(errno != EINPROGRESS)
{
printf("connect(%s:%s) failed\n", argv[1], argv[2]);
close(sockfd);
return -1;
}
}

pollfd fds;
fds.fd = sockfd;
fds.events = POLLOUT;
poll(&fds, 1, -1);
if(fds.revents == POLLOUT) printf("connect ok\n");
else printf("connect failed\n");

return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
// 终端
// 服务端
[zxc@study CppNetworkProgramming]$ ./tcpepoll 5008
listensock=3
accept client(socket=5) ok
client(eventfd=5) disconnected

// 客户端
[zxc@study CppNetworkProgramming]$ ./client1 192.168.112.132 5008
connect ok
[zxc@study CppNetworkProgramming]$ ./client1 19.168.112.132 5008
connect failed

非阻塞 I/O - accept()

  • 对非阻塞的 I/O 调用 accept(),如果已连接队列中没有 socket,函数立即返回失败,errno == EAGAIN

非阻塞 I/O - recv()

  • 对非阻塞的 I/O 调用 recv(),如果没数据可读(接收缓冲区为空),函数立即返回失败,errno == EAGAIN

非阻塞 I/O - send()

  • 对非阻塞的 I/O 调用 send(),如果 socket 不可写(发送缓冲区已满),函数立即返回失败,errno == EAGAIN

水平触发&边缘触发

  • select 和 poll 采用水平触发
  • epoll 有水平触发和边缘触发两种机制,缺省是水平触发

水平触发

  • 读事件:如果 epoll_wait 触发了读事件,表示有数据可读,如果程序没有把数据读完,再次调用 epoll_wait 的时候,将立即再次触发读事件
  • 写事件:如果发送缓冲区没有满,表示可以写入数据,只要缓冲区没有被写满,再次调用 epoll_wait 的时候,将立即再次触发写事件

边缘触发

  • 读事件:epoll_wait 触发读事件后,不管程序有没有处理读事件,epoll_wait 都不会再触发读事件,只有当新的数据到达时,才再次触发读事件
  • 写事件:epoll_wait 触发写事件之后,如果发送缓冲区仍可以写(发送缓冲区没有满),epoll_wait 不会再次触发写事件,只有当发送缓冲区由满变成不满时,才再次触发写事件
  • 水平触发的代码不能用于边缘触发,但是边缘触发的代码可以用于水平触发
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
/*
* 程序名:tcpepoll2.cpp,此程序用于演示采用 epoll 模型的边缘触发
*/
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <sys/fcntl.h>
#include <sys/epoll.h>

// 把 socket 设置成非阻塞
int setnonblocking(int fd)
{
int flags;

// 获取 fd 的状态
if((flags = fcntl(fd, F_GETFL, 0)) == -1) flags = 0;

return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

// 初始化服务端的监听端口。
int initserver(int port);

int main(int argc,char* argv[])
{
if(argc != 2)
{
printf("usage: ./tcpepoll2 port\n");
return -1;
}

// 初始化服务端用于监听的 socket
int listensock = initserver(atoi(argv[1]));
printf("listensock=%d\n", listensock);

if(listensock < 0)
{
printf("initserver() failed.\n");
return -1;
}

setnonblocking(listensock); // 把 listensock设置为非阻塞

// 创建 epoll 句柄
int epollfd = epoll_create(1);

// 为服务端的 listensock 准备读事件
epoll_event ev; // 声明事件的数据结构
ev.data.fd = listensock; // 指定事件的自定义数据,会随着 epoll_wait() 返回的事件一并返回
// ev.events = EPOLLIN; // 打算让 epoll 监视 listensock 的读事件,LT(水平)模式
ev.events = EPOLLIN | EPOLLET; //打算让 epoll 监视 listensock 的读事件,ET(边缘)模式

epoll_ctl(epollfd, EPOLL_CTL_ADD, listensock, &ev); //把需要监视的 socket 和事件加入 epollfd 中

epoll_event evs[10]; // 存放 epoll 返回的事件

while (true) // 事件循环
{
// 等待监视的 socket 有事件发生
int infds = epoll_wait(epollfd, evs, 10, -1);

// 返回失败
if(infds < 0)
{
perror("epoll() failed");
break;
}

// 超时
if(infds == 0)
{
printf("epoll() timeout\n");
continue;
}

//如果 infds>0,表示有事件发生的 socket 的数量
for(int ii = 0; ii < infds; ii++) // 遍历 epoll 返回的数组 evs
{
// printf("ptr=%s,events=%d\n",evs[ii].data.ptr,evs[ii].events);

//如果发生事件的是 listensock,表示有新的客户端连上来。
if (evs[ii].data.fd == listensock)
{
while (true)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int clientsock = accept(listensock, (struct sockaddr*)&client, &len);
if((clientsock < 0) && (errno == EAGAIN)) break; // 如果监听的 socket 是边缘触发的模式,一定要采用非阻塞的 socket,处理事件的时候一定要用循环

printf("accept client(socket=%d) ok.\n", clientsock);

// 为新客户端准备读事件,并添加到 epoll 中
setnonblocking(clientsock); // 把客户端连接的 socket 设置为非阻塞
ev.data.fd = clientsock;
// ev.events = EPOLLIN; // LT-水平触发。
ev.events = EPOLLIN | EPOLLET; // ET-边缘触发。
epoll_ctl(epollfd, EPOLL_CTL_ADD, clientsock, &ev);
}
}
else
{
/*
printf(" 触发了写件事\n");
for (int ii = 0; ii < 10000000; ii++)
{
if(send(ev.data.fd, "aaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbb", 30, 0) <= 0)
{
if (errno == EAGAIN)
{
printf("发送缓冲区已填满\n");
break;
}
}
}
*/
// 如果是客户端连接的 socke 有事件,表示有报文发过来或者连接已断开
char buffer[1024]; // 存放从客户端读取的数据
memset(buffer, 0, sizeof(buffer));
int readn; // 每次调用 recv() 的返回值
char* ptr = buffer; // buffer 的位置指针
while (true)
{
if((readn = recv(evs[ii].data.fd, ptr, 5, 0)) <= 0)
{
if((readn < 0) && (errno == EAGAIN))
{ // 如果数据被读取完了,把接收到的报文内容原封不动的发回去
send(evs[ii].data.fd, buffer, strlen(buffer), 0);
printf("recv(eventfd=%d):%s\n", evs[ii].data.fd, buffer);
}
else
{
// 如果客户端的连接已断开
printf("client(eventfd=%d) disconnected\n", evs[ii].data.fd);
close(evs[ii].data.fd); // 关闭客户端的 socket
}

break; // 跳出循环。
}else ptr = ptr + readn; // buffer 的位置指针后移
}
}
}
}

return 0;
}
// 初始化服务端的监听端口
int initserver(int port)
{
int sock = socket(AF_INET, SOCK_STREAM, 0);
if(sock < 0)
{
perror("socket() failed");
return -1;
}
int opt = 1;
unsigned int len = sizeof(opt);
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, len);

struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(port);

if(bind(sock, (struct sockaddr*)&servaddr, sizeof(servaddr)) < 0 )
{
perror("bind() failed");
close(sock);
return -1;
}
if(listen(sock,5) != 0 )
{
perror("listen() failed");
close(sock);
return -1;
}

return sock;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
/*
* 程序名:client2.cpp,此程序用于演示网络通讯的客户端程序
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <time.h>

int main(int argc, char* argv[])
{
if(argc != 3)
{
printf("usage:./client2 ip port\n");
return -1;
}

int sockfd;
struct sockaddr_in servaddr;
char buf[1024];

if((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
{
printf("socket() failed\n");
return -1;
}

memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(atoi(argv[2]));
servaddr.sin_addr.s_addr = inet_addr(argv[1]);

if(connect(sockfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) != 0)
{
printf("connect(%s:%s) failed\n", argv[1], argv[2]);
close(sockfd);
return -1;
}

printf("connect ok\n");

// printf("开始时间:%d", time(0));

for(int ii = 0; ii < 200000; ii++)
{
//从命令行输入内容。
memset(buf,0,sizeof(buf));
printf("please input:"); scanf("%s",buf);

//strcpy(buf,"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbccccccccccccccccddddddddddddd");

if(send(sockfd, buf, strlen(buf), 0) <= 0)
{
printf("write() failed\n");
close(sockfd);
return -1;
}

memset(buf,0,sizeof(buf));
if(recv(sockfd, buf, sizeof(buf), 0) <= 0)
{
printf("read() failed\n");
close(sockfd);
return -1;
}
printf("recv:%s\n",buf);
}

// printf("结束时间:%d", time(0));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 终端
// 服务端
[zxc@study CppNetworkProgramming]$ g++ -std=c++11 -g -o tcpepoll2 tcpepoll2.cpp
[zxc@study CppNetworkProgramming]$ ./tcpepoll2 5008
listensock=3
accept client(socket=5) ok.
recv(eventfd=5):aaaaaaaaaa
accept client(socket=6) ok.
recv(eventfd=6):bbbbbbbbbb
client(eventfd=6) disconnected
client(eventfd=5) disconnected
^C

// 1号客户端
[zxc@study CppNetworkProgramming]$ g++ -std=c++11 -g -o client2 client2.cpp
[zxc@study CppNetworkProgramming]$ ./client2 192.168.112.132 5008
connect ok
please input:aaaaaaaaaa
recv:aaaaaaaaaa
please input:^C

// 2号客户端
[zxc@study CppNetworkProgramming]$ ./client2 192.168.112.132 5008
connect ok
please input:bbbbbbbbbb
recv:bbbbbbbbbb
please input:^C

epoll 的原理

Epoll原理解析_epoll底层原理-CSDN博客

转载记录:防止文章丢失

本文核心思想是:要让读者清晰明白 Epoll 为什么性能好

文章会从网卡接收数据的流程讲起,串联起 CPU 中断、操作系统进程调度等知识; 再一步步分析阻塞接收数据、Select 到 Epoll 的进化过程; 最后探究 Epoll 的实现细节

从网卡接收数据说起

下边是一个典型的计算机结构图,计算机由 CPU、存储器(内存)与网络接口等部件组成,了解 Epoll 本质的第一步,要从硬件的角度看计算机怎样接收网络数据

image-20251014235838708

计算机结构图(图片来源:Linux 内核完全注释之微型计算机组成结构)

下图展示了网卡接收数据的过程:

  • 在 1 阶段,网卡收到网线传来的数据
  • 经过 2 阶段的硬件电路的传输
  • 最终 3 阶段将数据写入到内存中的某个地址上

这个过程涉及到 DMA 传输、IO 通路选择等硬件有关的知识,但我们只需知道:网卡会把接收到的数据写入内存

d52181c2837d4c43fe51e6ff8b2cfb52

网卡接收数据的过程

通过硬件传输,网卡接收的数据存放到内存中,操作系统就可以去读取它们

如何知道接收了数据?

了解 Epoll 本质的第二步,要从 CPU 的角度来看数据接收。理解这个问题,要先了解一个概念:中断

计算机执行程序时,会有优先级的需求。比如,当计算机收到断电信号时,它应立即去保存数据,保存数据的程序具有较高的优先级(电容可以保存少许电量,供 CPU 运行很短的一小段时间)

一般而言,由硬件产生的信号需要 CPU 立马做出回应,不然数据可能就丢失了,所以它的优先级很高

CPU 理应中断掉正在执行的程序,去做出响应; 当 CPU 完成对硬件的响应后,再重新执行用户程序

中断的过程如下图,它和函数调用差不多,只不过函数调用是事先定好位置,而中断的位置由“信号”决定

94e82f16adad681484af8e84aed4ab6b

中断程序调用

以键盘为例,当用户按下键盘某个按键时,键盘会给 CPU 的中断引脚发出一个高电平,CPU 能够捕获这个信号,然后执行键盘中断程序

下图展示了各种硬件通过中断与 CPU 交互的过程:

7e137cb47ecd89c1b631e22b9be1b5f7

CPU 中断(图片来源:net.pku.edu.cn)

现在可以回答 “如何知道接收了数据?” 这个问题了:当网卡把数据写入到内存后,网卡向 CPU 发出一个中断信号,操作系统便能得知有新数据到来,再通过网卡中断程序去处理数据

进程阻塞为什么不占用 CPU 资源?

了解 Epoll 本质的第三步,要从操作系统进程调度的角度来看数据接收。阻塞是进程调度的关键一环,指的是进程在等待某事件(如接收到网络数据)发生之前的等待状态,Recv、Select 和 Epoll 都是阻塞方法

下边分析一下进程阻塞为什么不占用 CPU 资源?为简单起见,我们从普通的 Recv 接收开始分析,先看看下面代码:

1
2
3
4
5
6
7
8
9
10
11
12
//创建socket 
int s = socket(AF_INET, SOCK_STREAM, 0);
//绑定
bind(s, ...)
//监听
listen(s, ...)
//接受客户端连接
int c = accept(s, ...)
//接收客户端数据
recv(c, ...);
//将数据打印出来
printf(...)

这是一段最基础的网络编程代码,先新建 Socket 对象,依次调用 Bind、Listen 与 Accept,最后调用 Recv 接收数据

Recv 是个阻塞方法,当程序运行到 Recv 时,它会一直等待,直到接收到数据才往下执行。那么阻塞的原理是什么

工作队列

操作系统为了支持多任务,实现了进程调度的功能,会把进程分为 “运行” 和 “等待” 等几种状态

运行状态是进程获得 CPU 使用权,正在执行代码的状态; 等待状态是阻塞状态,比如上述程序运行到 Recv 时,程序会从运行状态变为等待状态,接收到数据后又变回运行状态

操作系统会分时执行各个运行状态的进程,由于速度很快,看上去就像是同时执行多个任务

下图的计算机中运行着 A、B 与 C 三个进程,其中进程 A 执行着上述基础网络程序,一开始,这 3 个进程都被操作系统的工作队列所引用,处于运行状态,会分时执行

ef33418b177812c54197c3511a1aed45

工作队列中有 A、B 和 C 三个进程

等待队列

当进程 A 执行到创建 Socket 的语句时,操作系统会创建一个由文件系统管理的 Socket 对象(如下图)

d482555c4c90fea9e6be214406a9f034

创建 Socket

这个 Socket 对象包含了发送缓冲区、接收缓冲区与等待队列等成员。等待队列是个非常重要的结构,它指向所有需要等待该 Socket 事件的进程

当程序执行到 Recv 时,操作系统会将进程 A 从工作队列移动到该 Socket 的等待队列中(如下图)

e4f34f40d76dc4f6097b7f29529f542d

Socket 的等待队列

由于工作队列只剩下了进程 B 和 C,依据进程调度,CPU 会轮流执行这两个进程的程序,不会执行进程 A 的程序。所以进程 A 被阻塞,不会往下执行代码,也不会占用 CPU 资源

注:操作系统添加等待队列只是添加了对这个 “等待中” 进程的引用,以便在接收到数据时获取进程对象、将其唤醒,而非直接将进程管理纳入自己之下。上图为了方便说明,直接将进程挂到等待队列之下

唤醒进程

当 Socket 接收到数据后,操作系统将该 Socket 等待队列上的进程重新放回到工作队列,该进程变成运行状态,继续执行代码

同时由于 Socket 的接收缓冲区已经有了数据,Recv 可以返回接收到的数据

内核接收网络数据全过程

这一步,贯穿网卡、中断与进程调度的知识,叙述阻塞 Recv 下,内核接收数据的全过程

275f8f9b7ecf09c808d3ff452bbc2756

内核接收数据全过程

如上图所示,进程在 Recv 阻塞期间:

  • 计算机收到了对端传送的数据(步骤 ①)
  • 数据经由网卡传送到内存(步骤 ②)
  • 然后网卡通过中断信号通知 CPU 有数据到达,CPU 执行中断程序(步骤 ③)

此处的中断程序主要有两项功能,先将网络数据写入到对应 Socket 的接收缓冲区里面(步骤 ④),再唤醒进程 A(步骤 ⑤),重新将进程 A 放入工作队列中

唤醒进程的过程如下图所示:

8c3d864413f81be6c62cf42e761b9979

唤醒进程

以上是内核接收数据全过程,这里我们可能会思考两个问题:

  • 操作系统如何知道网络数据对应于哪个 Socket
  • 如何同时监视多个 Socket 的数据

第一个问题:因为一个 Socket 对应着一个端口号,而网络数据包中包含了 IP 和端口的信息,内核可以通过端口号找到对应的 Socket

当然,为了提高处理速度,操作系统会维护端口号到 Socket 的索引结构,以快速读取

第二个问题是多路复用的重中之重,也正是本文后半部分的重点

同时监视多个 Socket 的简单方法

服务端需要管理多个客户端连接,而 Recv 只能监视单个 Socket,这种矛盾下,人们开始寻找监视多个 Socket 的方法。Epoll 的要义就是高效地监视多个 Socket

从历史发展角度看,必然先出现一种不太高效的方法,人们再加以改进,正如 Select 之于 Epoll。先理解不太高效的 Select,才能够更好地理解 Epoll 的本质

假如能够预先传入一个 Socket 列表,如果列表中的 Socket 都没有数据,挂起进程,直到有一个 Socket 收到数据,唤醒进程。这种方法很直接,也是 Select 的设计思想

为方便理解,我们先复习 Select 的用法。在下边的代码中,先准备一个数组 FDS,让 FDS 存放着所有需要监视的 Socket

然后调用 Select,如果 FDS 中的所有 Socket 都没有数据,Select 会阻塞,直到有一个 Socket 接收到数据,Select 返回,唤醒进程

用户可以遍历 FDS,通过 FD_ISSET 判断具体哪个 Socket 收到数据,然后做出处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int s = socket(AF_INET, SOCK_STREAM, 0);   
bind(s, ...)
listen(s, ...)

int fds[] = 存放需要监听的socket

while(1){
int n = select(..., fds, ...)
for(int i=0; i < fds.count; i++){
if(FD_ISSET(fds[i], ...)){
//fds[i]的数据处理
}
}
}

Select 的流程

Select 的实现思路很直接,假如程序同时监视如下图的 Sock1、Sock2 和 Sock3 三个 Socket,那么在调用 Select 之后,操作系统把进程 A 分别加入这三个 Socket 的等待队列中

当任何一个 Socket 收到数据后,中断程序将唤起进程。下图展示了 Sock2 接收到了数据的处理流程:

bba9d63c280f4d06f77cdda4b0fdf6dc

Sock2 接收到了数据,中断程序唤起进程 A

注:Recv 和 Select 的中断回调可以设置成不同的内容

所谓唤起进程,就是将进程从所有的等待队列中移除,加入到工作队列里面,如下图所示:

2fd13040f4cf2999e4580ce94824efba

将进程 A 从所有等待队列中移除,再加入到工作队列里面

经由这些步骤,当进程 A 被唤醒后,它知道至少有一个 Socket 接收了数据。程序只需遍历一遍 Socket 列表,就可以得到就绪的 Socket

这种简单方式行之有效,在几乎所有操作系统都有对应的实现。但是简单的方法往往有缺点,主要是:

  • 每次调用 Select 都需要将进程加入到所有监视 Socket 的等待队列,每次唤醒都需要从每个队列中移除。这里涉及了两次遍历,而且每次都要将整个 FDS 列表传递给内核,有一定的开销

正是因为遍历操作开销大,出于效率的考量,才会规定 Select 的最大监视数量,默认只能监视 1024 个 Socket

  • 进程被唤醒后,程序并不知道哪些 Socket 收到数据,还需要遍历一次。

那么,有没有减少遍历的方法?有没有保存就绪 Socket 的方法?这两个问题便是 Epoll 技术要解决的

补充说明:本节只解释了 Select 的一种情形。当程序调用 Select 时,内核会先遍历一遍 Socket,如果有一个以上的 Socket 接收缓冲区有数据,那么 Select 直接返回,不会阻塞

这也是为什么 Select 的返回值有可能大于 1 的原因之一。如果没有 Socket 有数据,进程才会阻塞

Epoll 的设计思路

Epoll 是在 Select 出现 N 多年后才被发明的,是 Select 和 Poll (Poll 和 Select 基本一样,有少量改进)的增强版本。Epoll 通过以下一些措施来改进效率:

措施一:功能分离

Select 低效的原因之一是将“维护等待队列”和“阻塞进程”两个步骤合二为一

a38008a94da518d0413cc92ee63d6253

相比 Select,Epoll 拆分了功能

如上图所示,每次调用 Select 都需要这两步操作,然而大多数应用场景中,需要监视的 Socket 相对固定,并不需要每次都修改

Epoll 将这两个操作分开,先用 epoll_ctl 维护等待队列,再调用 epoll_wait 阻塞进程。显而易见地,效率就能得到提升

为方便理解后续的内容,我们先了解一下 Epoll 的用法。如下的代码中,先用 epoll_create 创建一个 Epoll 对象 Epfd,再通过 epoll_ctl 将需要监视的 Socket 添加到 Epfd 中,最后调用 epoll_wait 等待数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
int s = socket(AF_INET, SOCK_STREAM, 0);    
bind(s, ...)
listen(s, ...)

int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中

while(1){
int n = epoll_wait(...)
for(接收到数据的socket){
//处理
}
}

功能分离,使得 Epoll 有了优化的可能

措施二:就绪列表

Select 低效的另一个原因在于程序不知道哪些 Socket 收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的 Socket,就能避免遍历

81db3d019771d374b080fc2709b7206d

就绪列表示意图

如上图所示,计算机共有三个 Socket,收到数据的 Sock2 和 Sock3 被就绪列表 Rdlist 所引用

当进程被唤醒后,只要获取 Rdlist 的内容,就能够知道哪些 Socket 收到数据

Epoll 的原理与工作流程

本节会以示例和图表来讲解 Epoll 的原理和工作流程

创建 Epoll 对象

如下图所示,当某个进程调用 epoll_create 方法时,内核会创建一个 eventpoll 对象(也就是程序中 Epfd 所代表的对象)

adc7094b02e2e8373fbfb5f969f34be1

内核创建 eventpoll 对象

eventpoll 对象也是文件系统中的一员,和 Socket 一样,它也会有等待队列

创建一个代表该 Epoll 的 eventpoll 对象是必须的,因为内核要维护“就绪列表”等数据,“就绪列表”可以作为 eventpoll 的成员

维护监视列表

创建 Epoll 对象后,可以用 epoll_ctl 添加或删除所要监听的 Socket。以添加 Socket 为例

b0e1ac15912f6274f7ac2165244e4909

添加所要监听的 Socket

如上图,如果通过 epoll_ctl 添加 Sock1、Sock2 和 Sock3 的监视,内核会将 eventpoll 添加到这三个 Socket 的等待队列中

当 Socket 收到数据后,中断程序会操作 eventpoll 对象,而不是直接操作进程

接收数据

当 Socket 收到数据后,中断程序会给 eventpoll 的 “就绪列表” 添加 Socket 引用

69133c53039a54a3e2846db95fef158f

给就绪列表添加引用

如上图展示的是 Sock2 和 Sock3 收到数据后,中断程序让 Rdlist 引用这两个 Socket

eventpoll 对象相当于 Socket 和进程之间的中介,Socket 的数据接收并不直接影响进程,而是通过改变 eventpoll 的就绪列表来改变进程状态

当程序执行到 epoll_wait 时,如果 Rdlist 已经引用了 Socket,那么 epoll_wait 直接返回,如果 Rdlist 为空,阻塞进程

阻塞和唤醒进程

假设计算机中正在运行进程 A 和进程 B,在某时刻进程 A 运行到了 epoll_wait 语句

45852193f950f5a1b4f3ccfba9a42855

epoll_wait 阻塞进程

如上图所示,内核会将进程 A 放入 eventpoll 的等待队列中,阻塞进程

当 Socket 接收到数据,中断程序一方面修改 Rdlist,另一方面唤醒 eventpoll 等待队列中的进程,进程 A 再次进入运行状态(如下图)

731ec624bafb3fa785c648d66d0dddf3

Epoll 唤醒进程

也因为 Rdlist 的存在,进程 A 可以知道哪些 Socket 发生了变化

Epoll 的实现细节

至此,相信读者对 Epoll 的本质已经有一定的了解。但我们还需要知道 eventpoll 的数据结构是什么样子

此外,就绪队列应该使用什么数据结构?eventpoll 应使用什么数据结构来管理通过 epoll_ctl 添加或删除的 Socket

155b5b2f11f89014e6d28997cfe5cf46

Epoll 原理示意图,图片来源:《深入理解 Nginx:模块开发与架构解析(第二版)》,陶辉

如上图所示,eventpoll 包含了 Lock、MTX、WQ(等待队列)与 Rdlist 等成员,其中 Rdlist 和 RBR 是我们所关心的

就绪列表的数据结构

就绪列表引用着就绪的 Socket,所以它应能够快速的插入数据。程序可能随时调用 epoll_ctl 添加监视 Socket,也可能随时删除

当删除时,若该 Socket 已经存放在就绪列表中,它也应该被移除。所以就绪列表应是一种能够快速插入和删除的数据结构

双向链表就是这样一种数据结构,Epoll 使用双向链表来实现就绪队列(对应上图的 Rdlist)

索引结构

既然 Epoll 将“维护监视队列”和“进程阻塞”分离,也意味着需要有个数据结构来保存监视的 Socket,至少要方便地添加和移除,还要便于搜索,以避免重复添加

红黑树是一种自平衡二叉查找树,搜索、插入和删除时间复杂度都是 O(log(N)),效率较好,Epoll 使用了红黑树作为索引结构(对应上图的 RBR)

注:因为操作系统要兼顾多种功能,以及有更多需要保存的数据,Rdlist 并非直接引用 Socket,而是通过 Epitem 间接引用,红黑树的节点也是 Epitem 对象

同样,文件系统也并非直接引用着 Socket。为方便理解,本文中省略了一些间接结构

总结

Epoll 在 Select 和 Poll 的基础上引入了 eventpoll 作为中间层,使用了先进的数据结构,是一种高效的多路复用技术

这里也以表格形式简单对比一下 Select、Poll 与 Epoll,结束此文。希望读者能有所收获

4edd9efb13b6b7ad3a4ed9e16ebeea3a

图片来源《Linux 高性能服务器编程》

  • Titre: C++网络编程
  • Auteur: tiny_star
  • Créé à : 2025-10-05 12:01:02
  • Mis à jour à : 2025-10-15 00:35:57
  • Lien: https://tiny-star3.github.io/2025/10/05/Cpp/C++NetworkProgramming/
  • Licence: Cette œuvre est sous licence CC BY-NC-SA 4.0.
Commentaires