tcp/ip编程

代码仓库:tcpip
建议先clone下来配合书籍内容啃食,我的代码全都能跑,改了书上的一些错误。

这篇文章是《TCP/IP 网络编程》的阅读笔记,一开始我记录得很详细,几乎是抄书,因为前面都是讲解原理,我想越详细越好,但是到后面我不会事无巨细了,我只记录感兴趣的、重要的和不懂的

作者同时讲解了linux和windows下的网络编程,笔记中写到的函数声明,其后面的注释一般都是解释返回值的,S代表Success,函数执行成功时的返回值,F代表Failure,函数执行失败时的返回值,但并不是所有的都用S、F这种记法

在第一章会给出Linux和Windows下可用的服务端和客户端应用代码,不要过分纠结其中看不懂的东西,因为前四章可以说就是为了说明白第一章的代码而存在的,直接往下看就好

  • Part1:ch1~ch14 开始网络编程
  • Part2:ch15~ch18 基于Linux的编程
  • Part3:ch19~ch23 基于Windows的编程
  • Part4:ch24~ch25 结束网络编程

Part1 开始网络编程(ch1~ch14)

Chapter1 理解网络编程和套接字

1.1 理解网络编程和套接字

网络编程和套接字概要

网络编程 就是编写程序使两台计算机之间交换数据,套接字(socket) 是网络数据传输用的软件设备,是由操作系统提供的部件

构建“接电话”套接字

作者形象地将接受连接请求的套接字创建比喻成了接电话,其过程是:

  1. 调用socket函数创建套接字(安装电话机)
  2. 调用bind函数分配ip地址和端口号(分配电话号码)
  3. 调用listen函数转化为可接收请求状态(连接电话线)
  4. 调用accept函数受理连接请求(拿起话筒)
1
2
3
4
5
#include <sys/socket.h>
int socket(int domain, int type, int protocol) // 成功返回文件描述符,失败返回-1
int bind(int sockfd, struct sockaddr* myaddr, socklen_t addrlen) // 成功0,失败-1
int listen(int sockfd, int backlog) // 成功0,失败-1
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen) // 成功返回文件描述符,失败返回-1
编写“Hello World!”服务器端

下面的代码只要看看之前提到的四个函数的调用过程就好

{.line-numbers}
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
// hello_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
void error_handling(char *message);

int main(int argc, char** argv) {
int serv_sock;
int clnt_sock;

struct sockaddr_in serv_addr;
struct sockaddr_in clnt_addr;
socklen_t clnt_addr_size;

char message[]="Hello World!";

if (argc != 2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}

serv_sock = socket(PF_INET, SOCK_STREAM, 0);
if (serv_sock == -1)
error_handling("socket() error");

memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(atoi(argv[1]));

if (bind(serv_sock, (struct sockaddr*) &serv_addr, sizeof(serv_addr)) == -1)
error_handling("bind() error");

if (listen(serv_sock, 5) == -1)
error_handling("listen() error");

clnt_addr_size = sizeof(clnt_addr);
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_addr, &clnt_addr_size);
if (clnt_sock == -1)
error_handling("accept() error");

write(clnt_sock, message, sizeof(message));
close(clnt_sock);
close(serv_sock);
return 0;
}

void error_handling(char *message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
  • 24:socket
  • 33:bind
  • 36:listen
  • 40:accept
构建打电话(请求连接)套接字(客户端)
1
2
#include <sys/socket.h>
int connect(int sockfd, struct sockaddr *serv_addr, socklen_t addrlen); // 成功0,失败-1

客户端只需要“调用socket函数创建套接字”和“调用connect函数向服务器发送连接请求”这两个步骤

{.line-numbers}
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
// hello_client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
void error_handling(char *message);

int main(int argc, char* argv[]) {
int sock;
struct sockaddr_in serv_addr;
char message[30];
int str_len;

if (argc != 3) {
printf("Usage: %s <IP> <port>\n", argv[0]);
exit(1);
}

sock = socket(PF_INET, SOCK_STREAM, 0);
if (sock == -1)
error_handling("socket() error");

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

if (connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("connect() error");

str_len = read(sock, message, sizeof(message)-1);
if (str_len == -1)
error_handling("read() error");

printf("Message from server : %s \n", message);
close(sock);

return 0;
}

void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
  • 21:socket创建套接字,后面调用bind、listen将成为服务端套接字,调用connect将成为客户端套接字
  • 30:connect向服务端发送连接请求
在Linux平台下运行

假设编译出来的可执行文件为hserverhclient,那么开两个终端这样运行

先开服务端监听

1
./hserver 9190

再运行客户端请求连接

1
./hclient 127.0.0.1 9190

此时在客户端终端界面应该会传送过来一条信息

1
Message from server : Hello World!

运行完之后会服务端和客户端都会停止,而且无法使用同一个端口号立即重新运行,这个以后再讲

使用IP地址为127.0.0.1是因为客户端和服务端都运行在同一台计算机上,如果运行在不同的计算机上,则要将IP换成服务端程序所在的计算机IP

1.2 基于Linux的文件操作

在Linux中,socket也被认为是文件的一种,因此在网络数据传输的过程中自然可以使用文件I/O的函数,Windows和Linux不同,要区分socket和文件,因此在Windows下传输网络数据需要使用特殊的数据传输相关函数

底层文件访问(Low-Level File Access)和文件描述符(File Descriptor)

“底层”可以理解为“与标准无关的、操作系统独立提供的”,稍后讲解的都是非标准的函数

文件描述符(文件句柄:Windows中的叫法)是为了方便“称呼”文件或套接字而赋予的整数,在Linux系统中,C语言的标准输入输出和标准错误也被赋予了文件描述符,只不过它们不需要我们手动创建,而是程序开始后自动分配的

File Descriptor Object
0 Standard Input
1 Standard Output
2 Standard Error

文件和套接字的文件描述符来源不同,文件的是通过open函数打开它而创建,套接字的则是通过socket函数创建,但对文件描述符的操作并无区别

打开文件
1
2
3
4
5
6
7
#include <sys/type.h>
#include <sys/stat.h>
#include <fcntl.h> // file control

// path 文件名的字符串路径
// flag 文件的打开模式
int open(const char *path, int flag); // 成功返回文件描述符,失败返回-1
打开模式(flag) 含义
O_CREAT 必要时创建文件
O_TRUNC 删除全部现有数据
O_APPEND 维持现有数据,将添加内容保存到其后面
O_RDONLY 只读
O_WRONLY 只写
O_RDWR 读写

可以使用或运算以多种模式打开文件

关闭文件

close函数用于关闭打开的文件,作用对象是文件描述符

1
2
3
4
#include <unistd.h>

// fd 需要关闭的文件或套接字的文件描述符
int close(int fd); // 成功时返回0,失败返回-1
将数据写入文件

write函数用于向文件输出(传输)数据,作用对象也是文件描述符

1
2
3
4
5
6
#include <unistd.h>

// fd 显示数据传输对象的文件描述符
// buf 保存要传输数据的缓冲地址
// nbytes 要传输数据的字节数
ssize_t write(int fd, const void* buf, size_t nbytes); // 成功时返回写入的字节数,失败时返回-1

size_tunsigned intssize_tsigned int,它们是使用typedef定义的类型,不同时代的系统int位数是不一样的,而使用size_t就能很方便进行修改,只需要在typedef处更改即可

读文件中的数据

read函数用于输入(接收)数据,作用对象依然是文件描述符

1
2
3
4
5
6
#include <unistd.h>

// fd 显示数据接收对象的文件描述符
// buf 要保存接收数据的缓冲地址
// nbytes 要接收数据的最大字节数
ssize_t read(int fd, void* buf, size_t nbytes); // 成功时返回接收的字节数(但是直接遇到文件结尾则返回0),失败时返回-1
文件描述符与套接字

作者这里写了个同时创建文件和套接字的程序,然后打印出文件描述符,文件描述符从3开始从小到大整数递增,因为0、1、2已经分配给标准I/O了

1.3 基于Windows平台的实现

Windows套接字和Linux的在很多地方都很类似

同时学习Windows和Linux的原因

大多数项目的服务端在Linux上而客户端在Windows上,而且它们的套接字编程很类似

为Windows套接字编程设置头文件和库

Winsock的基础上开发网络程序,需要:

  • 导入头文件winsock2.h
  • 连接ws2_32.lib

至此只需要在源文件中添加头文件winsock2.h就能调用Winsock相关函数了

Winsock的初始化

调用WSAStartup函数,设置使用的Winsock版本(Winsock有多个版本,需要手动设置一下),并初始化相应版本的库

1
2
3
4
5
#include <winsock2.h>

// wVersionRequested 程序中用到的winsock版本
// lpWSAData WSADATA结构体变量指针
int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData); // 成功时返回0,失败时返回非0的错误代码

使用MAKEWORD传递版本信息

1
2
MAKEWORD(1, 2);  // 主版本为1,副版本为2,返回0x0201
MAKEWORD(2, 2); // 主版本为2,副版本为2,返回0x0202

“Winsock初始化公式”

1
2
3
4
5
6
7
8
int main(int argc, char** argv) {
WSADATA wsaData;
// ...
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error!");
// ...
return 0;
}

注销Winsock

1
2
3
#include <winsock2.h>

int WSACleanup(void); // 成功时返回0,失败时返回SOCKET_ERROR

调用该函数之后,Winsock的相关函数便无法再使用,一般在程序将要结束前调用

1.4 基于Windows的套接字相关函数及示例

只是介绍,体会一下和Linux的相似性

基于Windows的套接字相关函数
1
2
3
4
5
6
7
8
9
10
#include <winsock2.h>

// 开始前需要WSAStartup初始化
SOCKET socket(int af, int type, int protocol); // S: 套接字句柄 F:INVALID_SOCKET
int bind(SOCKET s, const struct sockaddr* name, int namelen); // S:0 F:SOCKET_ERROR
int listen(SOCKET s, int backlog); // S:0 F:SOCKET_ERROR
SOCKET accept(SOCKET s, const struct sockaddr* addr, int addrlen); // S: 套接字句柄 F:INVALID_SOCKET
int connect(SOCKET s, const struct sockaddr* name, int namelen); // S:0 F:SOCKET_ERROR
int closesocket(SOCKET s); // S:0 F:SOCKET_ERROR
// 结束后需要WSACleanup注销

在Linux中关闭文件和套接字都是使用close函数,而在windows中关闭套接字有专用函数closesocket,其他的并没有太大差别

Windows中的文件句柄和套接字句柄

Windows中的文件句柄和套接字句柄是有区别的,不像Linux中操作文件和套接字都是文件描述符,可以用完全一样的函数操作,Windows中操作文件句柄和套接字句柄会使用不同的函数

send & recv
1
2
3
4
5
6
7
8
#if _WIN32
#include <winsock2.h>
#else
#include <sys/socket.h>
#endif

ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);

通用返回值说明

  1. >0:成功,返回实际读写的字节数
  2. 0
    • recv:对方关闭连接(收到EOF)
    • send:极少出现0,代表无数据发送
  3. -1:出错,errno记录错误;非阻塞下EAGAIN/EWOULDBLOCK代表缓冲区暂时不可读/不可写,不算故障

recv 参数解释

  1. int sockfd
    已建立连接的TCP客户端套接字文件描述符。

  2. void *buf
    接收缓冲区指针,内核会把从网络读到的数据拷贝到这块内存。

  3. size_t len
    缓冲区最大可接收字节长度,不能超过buf分配的内存大小。

  4. int flags 控制标志(常用)

    • 0:普通阻塞读写,无特殊行为
    • MSG_PEEK:窥探数据,数据留在内核缓冲区,下次recv仍能读到
    • MSG_OOB:读取TCP带外紧急数据
    • MSG_WAITALL:阻塞直到读满len字节才返回(非阻塞无效)

send 参数解释

  1. int sockfd
    已连接TCP套接字fd。

  2. const void *buf
    待发送数据缓冲区,const代表不会修改内容。

  3. size_t len
    想要发送的数据总字节长度。

  4. int flags 常用标志

    • 0:常规发送
    • MSG_NOSIGNAL:向关闭的连接发送数据时,不触发SIGPIPE信号
    • MSG_OOB:发送TCP紧急带外数据
    • MSG_MORE:本次数据后还有数据,内核暂时不发送,缓存合并发包

理解send&recv

  1. TCP流式特性

    send/recv 不保证一次收发全部数据:

    • send:内核发送缓冲区满时,仅拷贝部分数据,需循环发送剩余内容
    • recv:对方分次发包,一次只能读到部分数据,需循环读取
  2. 阻塞/非阻塞行为差异

    • 阻塞fd:缓冲区无数据(recv)/满(send)时,函数卡死等待
    • 非阻塞fd:无空间/无数据立刻返回-1,errno=EAGAIN
  3. 与read/write区别

    read/write 没有flags参数;recv/send 多了第四参数,支持网络专属控制选项,网络编程优先使用recv/send。

1
2
3
4
5
6
char buf[1024];
// 读取最多1024字节,普通模式
ssize_t n = recv(clnt_fd, buf, sizeof(buf), 0);

// 发送10字节数据,不产生SIGPIPE
send(clnt_fd, buf, 10, MSG_NOSIGNAL);
创建基于Windows的服务器端和客户端

目前只需要理解套接字相关函数的调用过程,套接字库的初始化过程和注销即可

我使用 VisualStudio 2022 进行学习,新建好项目之后会生成一个解决方案,一个解决方案底下可以新建多个项目,可以把server和client项目都建立在同一个解决方案下

项目名称上右键->属性(可以看到打开属性有个快捷键Alt+Enter)->链接器->附件依赖项填写ws2_32.lib,然后在调试->命令参数里面填写命令行参数(服务端是一个端口号,我填的是9091;客户端是ip和端口号,我填的是 127.0.0.1 9091),以空格分隔多个参数

解决方案名称上右键,打开属性菜单,设置启动项目,因为我们要分服务端和客户端执行,并且服务端要先执行,所以肯定不能是“单启动项目”(默认是这个),把它设置为“当前选定内容”,这样你选中哪个项目执行就是执行那个项目生成的可执行文件;或者设置为“多个启动项目”,同时在项目依赖项里面“项目”栏选中为客户端,勾选“依赖于”栏的服务端,这样你开始执行的时候vs就会自动先执行服务端,然后执行客户端

如我创建的项目结构,解决方案为vsWindowsNet,服务端项目为vsWindowsNetServer,客户端项目为vsWindowsNetClient

项目结构

当然也可以用gcc编译(我使用的是MinGW64),使用命令行

1
2
gcc .\hello_server_win.c -o server.exe -lws2_32
gcc .\hello_client_win.c -o client.exe -lws2_32

执行服务端

1
.\server.exe 9091

执行客户端与结果

1
2
.\client.exe 127.0.0.1 9091
Message from server: Hello Windows Network Program!

其实我也非常喜欢轻量化的环境,我使用vs是因为之前没有用过这款神一般的IDE,有必要学习一下,利用它开发很方便,可以省去很多不必要的麻烦,而且它的调试器很好用,不过我目前还不太会

{.line-numbers}
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
// hello_server_win.c
#include <stdio.h>
#include <stdlib.h>
#include <winsock2.h>
void ErrorHandling(char* message);

int main(int argc, char* argv[]) {
WSADATA wsaData;
SOCKET hServSock, hClntSock;
SOCKADDR_IN servAddr, clntAddr;
int szClntAddr;
char message[] = "Hello Windows Network Program!";

if (argc != 2) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}

if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error!");

hServSock = socket(PF_INET, SOCK_STREAM, 0);
if (hServSock == INVALID_SOCKET)
ErrorHandling("socket() error!");

memset(&servAddr, 0, sizeof(servAddr));
printf("servaddr size: %zu\n", sizeof(servAddr));
servAddr.sin_family = AF_INET;
servAddr.sin_addr.S_un.S_addr = htonl(INADDR_ANY);
servAddr.sin_port = htons(atoi(argv[1]));

if (bind(hServSock, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR)
ErrorHandling("bind() error!");

if (listen(hServSock, 5) == SOCKET_ERROR)
ErrorHandling("listen() error!");

szClntAddr = sizeof(clntAddr);
hClntSock = accept(hServSock, (SOCKADDR*)&clntAddr, &szClntAddr);
if (hClntSock == INVALID_SOCKET)
ErrorHandling("accept() error!");

send(hClntSock, message, sizeof(message), 0);
closesocket(hClntSock);
closesocket(hServSock);
WSACleanup();

return 0;
}

void ErrorHandling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
  • 19:WSAStartup初始化套接字库
  • 22、31:socket创建套接字、bind给套接字分配IP地址和端口号
  • 35:listen使创建的套接字成为服务器套接字
  • 39:accept受理客户端连接请求
  • 43:send向第39行连接的客户端传输数据
  • 46:WSACleanup注销19行初始化的套接字库

可以看到除了需要初始化和注销,其余的基本都是类似的

注:第29行的结构体成员和原书写的不一样,我这个是正确的,可能是结构体有变动或者是原书笔误

下面是客户端代码,需要注意的是,原书里用到的一些函数被弃用了,第29行的inet_pton(...)是新的函数,将字符串参数转换为ip地址,它在头文件ws2tcpip.h中被声明

{.line-numbers}
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
// hello_client_win.c
#include <stdio.h>
#include <stdlib.h>
#include <winsock2.h>
#include <ws2tcpip.h>
void ErrorHandling(char* message);

int main(int argc, char** argv) {
WSADATA wsaData;
SOCKET hSocket;
SOCKADDR_IN servAddr;

char message[32];
int strLen;
if (argc != 3) {
printf("Usage: %s <ip> <port>\n", argv[0]);
exit(1);
}

if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error!");

hSocket = socket(PF_INET, SOCK_STREAM, 0);
if (hSocket == INVALID_SOCKET)
ErrorHandling("socket() error!");

memset(&servAddr, 0, sizeof(servAddr));
servAddr.sin_family = AF_INET;
inet_pton(AF_INET, argv[1], &servAddr.sin_addr.S_un.S_addr);
servAddr.sin_port = htons(atoi(argv[2]));

if (connect(hSocket, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR)
ErrorHandling("connect() error!");

strLen = recv(hSocket, message, sizeof(message) - 1, 0);
if (strLen == -1)
ErrorHandling("recv() error!");
printf("Message from server: %s\n", message);

closesocket(hSocket);
WSACleanup();
return 0;
}

void ErrorHandling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
  • 20:WSAStartup初始化Winsock库
  • 23、32:socket创建套接字、connect通过套接字向服务器发出连接请求
  • 35:recv接收服务器发来的数据
  • 42:WSACleanup注销Winsock库
基于Windows的I/O函数
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <winsock2.h>

// s 数据传输对象的套接字句柄值
// buf 保存待传输的数据地址值
// len 要传输的字节数
// flags 传输数据时用到的多种选项信息
int send(SOCKET s, const char* buf, int len, int flags); // S:传输字节数 F:SOCKET_ERROR

// s 数据接收对象的套接字句柄值
// buf 保存接收数据的缓冲地址值
// len 能够接收的最大字节数
// flags 接收数据时用到的多种选项信息
int revc(SOCKET s, const char* buf, int len, int flags); // S:传输字节数(收到EOF则返回0) F:SOCKET_ERROR

windows中套接字句柄需要使用专门的函数传输/接收数据,而linux中文件和套接字都能用相同的函数进行数据I/O,但是linux中也有函数专门操作套接字(同windows有send、recv),这个以后再说

Chapter2 套接字类型与协议设置

这一章是原理篇,会相对比较枯燥,不过很重要,一点点来吧,仅仅关注创建套接字时使用的socket函数就好

2.1 套接字协议及其数据传输特性

关于协议(protocol)

计算机间对话必备的通信规则、完成数据交换而制定的约定

创建套接字
1
2
3
4
5
6
#include <sys/socket.h>

// domain 套接字中使用的协议族(Protocol Family)信息
// type 套接字中数据传输类型信息
// protocol 计算机间通信使用的协议信息
int socket(int domain, int type, int protocol); // S:文件描述符 // F:-1
协议族(Protocol Family)

socket的第一个参数domain是协议族,它规定了第三个参数protocol的范围

sys/socket.h中声明的PF

名称 协议族
PF_INET IPv4互联网协议
PF_INET6 IPv6互联网协议
PF_LOCAL 本地通信的UNIX协议族
PF_PACKET 底层套接字的协议族
PF_IPX IPX Novell协议族
套接字类型(type)

套接字类型指的是套接字的数据传输方式,通过socket第二个参数传递,没错,一个协议族也存在多种数据传输方式

下面介绍最具代表性的两种数据传输方式,一定要掌握

套接字类型1:面向连接的套接字(SOCK_STREAM)

如同发货点和收货点间的传送带,货物(数据)通过传送带传输,它的特征如下

  • 传输过程中数据不会消失
  • 按序传输数据
  • 传输的数据不存在数据边界(Boundary)

前两条好理解,最后一条说明一下帮助理解

收发数据的套接字内部有缓冲(buffer), 简而言之就是字节数组,通过套接字传输的数据将保存到该数组,因此read/write的调用次数实际上并没有太大意义,比如要接收的数据可能是等缓冲区填满了一次性read出来或者分多次read,所以说它传输的数据没有数据边界

另外,即使缓冲区满了数据也不会丢失,因为一端套接字缓冲区满了再也无法接收数据的时候,另一端套接字就会停止传输,也就是说,面向连接的套接字会根据接收端的状态传输数据,如果传输出错还能提供重传服务,除了特殊情况面向连接的套接字不会发生数据丢失

除此之外,套接字连接必须要一一对应(服务端和客户端)

作者的总结:可靠的、按序传递的、基于字节的、面向连接的数据传输方式的套接字

套接字类型2:面向消息的套接字(SOCK_DGRAM)

如同高速摩托运送快递(数据),特征如下

  • 强调快速传输而非传输顺序
  • 传输的数据可能丢失也可能损毁
  • 传输的数据有数据边界
  • 限制每次传输的数据大小

存在数据边界意味着接收数据的次数应和传输次数相同

作者的总结:不可靠的、不按序传递的、以数据的高速传输为目的的套接字

另外,面向消息的套接字不存在连接的概念,这个以后再说

协议的最终选择

实际上在大部分情况下,确定了协议族和数据传输方式后(socket函数的前两个参数),就能确定具体的协议了,所以socket函数的第三个参数大部分情况下可以直接写”0”,但是有一些协议族中有数据传输方式相同的协议,这就需要protocol参数确定使用的协议了

面向连接的IPv4协议族协议只有IPPROTO_TCP,面向消息的IPv4协议族协议只有IPPROTO_UDP,下面创建TCP套接字和UDP套接字,实际上第三个参数可以直接写0,因为前两个参数确定了唯一的协议

1
2
int tcp_socket = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
int udp_socket = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP);
面向连接的套接字:TCP套接字示例

用前面章节的代码验证面向连接的套接字传输的“数据不存在边界”,只需将write和read调用次数不同即可,可以让只增加客户端read调用次数,分多次接收服务端发送的数据

1
2
3
4
5
6
// ...
while(read_len = read(sock, &message[idx++], 1)) {
if (read_len == -1)
error_handing("read() error!");
}
// ...

Windows上和Linux的改法一样,就不赘述了

Chapter3 地址族与数据序列

分配套接字的IP地址和端口号

IP(Internet Protocol)是为了收发网络数据而分配给计算机的值,端口号是为了区分程序中的套接字而分配给套接字的序号

网络地址(Internet Address)
  • IPv4(Internet Protocol version 4) 4字节地址族 AF_INET
  • IPv6(Internet Protocol version 6) 16字节地址族 AF_INET6

注:IP地址分A类、B类、C类、D类(多播IP地址),其4字节由网络ID主机ID占用,ABCD类IP地址的网络ID分别占1、2、3、4字节

数据传输并不是一步到位的,而是先传输到与数据报中IP地址相同网络地址的路由器或者交换机,再由路由器或者交换机传输到相应的主机(当然,路由器或交换机也能传到路由器或交换机)

可以看看这篇文章补一补网络的基础知识:如何学习网络编程

未来链接失效也没关系,你只需要知道数据是怎么通过IP实现正确转发的,以及网络模型每一层的功能(虽然这个不知道也没关系),这些应该有很多资源可供学习

提一下上面文章重要的知识点:
如果机器A给B发送数据,发现B和它不在同一个网段(ip和子网掩码进行与运算),就会把它发送给默认网关(默认网关是路由器的ip),最终通过物理地址将数据传输给确定的机器,网络层(IP协议)本身没有传输包的功能,数据包的实际传输是委托给数据链路层(以太网中的交换机)来实现的

至于如何获取用于识别各种机器的ip、MAC(物理地址),以及确定ip和MAC的映射关系、ip/MAC和端口的映射关系,都是有一定方法的,这个建议看文章了解

网络地址分类与主机地址边界

通过IP地址的第一个字节就能判断网络地址占用的字节数,因为我们根据IP地址的边界区分网络地址

  • A类地址的首字节范围:0~127
  • B类地址的首字节范围:128~191
  • C类地址的首字节范围:192~223

以及下面的表述方式(从左到右是二进制最高位->低位):

  • A类地址首位以0开始
  • B类地址前2位以10开始
  • C类地址前3位以110开始
用于区分套接字的端口号

IP用于区分计算机,只要有IP地址就能向目标主机发送数据,但是无法向确定的应用程序传输数据,这时候就要用到端口号了

计算机中一般配有网卡(NIC,Network Interface Card)这个数据传输设备,通过NIC接收的数据内有端口号,操作系统正是参考此端口号把数据传输给相应端口的套接字

端口号就是在同一操作系统内为区分不同的套接字而设置的,因此无法将同一个端口号分配给不同的套接字,端口号由16位构成,可分配的端口号范围是0-65535,但0~1023是知名端口(Well-Known PORT),一般分配给特定的应用程序,所以应当分配此范围之外的值

另外,虽然端口号不能重复,但是TCP套接字和UDP套接字不会共用端口号,所以允许端口号重复,只是协议相同的不允许罢了

总之,数据传输目标地址要同时包含IP地址和端口号,只有这样数据才能被传输到最终的目标应用程序(即应用程序套接字)

3.2 地址信息的表示

表示IPv4地址的结构体
1
2
3
4
5
6
7
8
9
10
11
12
struct sockaddr_in
{
sa_family_t sin_family; // 地址族(Address Family)
uint16_t sin_port; // 16位TCP/UDP端口号
struct in_addr sin_addr; // 32位IP地址
char sin_zero[8]; // 不使用
};

struct in_addr
{
in_addr_t s_addr; // 32位IPv4地址
};

使用自定义类型的好处在于无论在多少位的机器上它们都可以是一样的位数,或根据需要变动位数而无需使用不同类型

结构体sockaddr_in成员分析
  • sin_family:每种协议适用的地址族均不同,IPv4使用4字节地址族,IPv6使用16字节地址族
  • sin_port:保存16位端口号,重点在于他以网络字节序保存,也有in_port_t定义为uint16_t
  • sin_addr:保存32位IP地址,也以网络字节序保存,in_addr_t实际上是uint32_t
  • sin_zero:8字节占位符,为了与结下来要讲的sockaddr结构体保持一致而用

之前我们调用bind是这样的

1
2
3
4
5
struct sockaddr_in serv_addr;
...
if (bind(serv_sock, (struct sockaddr*) &serv_addr, sizeof(serv_addr)) == -1)
error_handling("bind() error");
...

第二个参数强转成了sockaddr指针,实际上bind的第二个参数也确实是那样,但我们传参使用的是sockaddr_in结构体地址,为什么不直接用sockaddr结构体呢?看一下就会明白了

1
2
3
4
5
struct sockaddr
{
sa_family_t sin_family; // 地址族(Adress Family)
cahr sa_data[14]; // 地址信息
};

对比一下sockaddr_insockaddr结构体不难发现为什么前者要用8个字节的占位符,而后者则是将端口号和IP地址都保存在了sa_data里面,这样我们填写地址信息就比较麻烦,至于为什么用sockaddr而不直接用sockaddr_in,因为我们的协议族不止有IPv4,它要能够作为一个通用的结构体存储地址信息

3.3 网络字节序与地址变换

这涉及到CPU大小端的问题:CPU中的大端与小端

简单来说就是

  • Little endian:将低位字节存储在低位地址
  • Big endian:将高位字节存储在低位地址

这篇文章里提到的一个判别CPU是大端还是小端的方法我感觉很好

1
2
3
4
5
6
7
8
bool isBigEndian(){
union{
long i;
char c[sizeof(long)];
}un;
un.i = 1;
return un.c[sizeof(long)-1]==1;
}

其实用union的特性就能判断了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include<stdio.h>
union
{
short x;
char a[2];
} U;

int main()
{
U.a[0]=0x01;
U.a[1]=0x02;
printf("%x\n", U.x);
return 0;
}
// X86 OUTPUT: 0201, it's Little Endian
字节序(Order)与网络字节库

CPU向内存中保存数据的方式有2种(大小端),那么CPU解析数据的方式也有两种

由此可见不同主机保存数据的方式可能所不同,这样在进行网络数据传输的时候就牵扯到字节序的问题

我们约定通过网络传输数据的的时候统一为大端序,意即先把数据数组转化成大端序格式再进行网络传输

字节序转换(Endian Conversion)

帮助转换字节序的函数

1
2
3
4
unsigned short htons(unsigned short);
unsigned short ntohs(unsigned short);
unsigned short htonl(unsigned long);
unsigned short ntohl(unsigned long);

h表示host,代表主机字节序;n表示net代表网络字节序;sl表示short和long,代表需要转化的是什么类型数据

除了向socket_in结构体填充数据外,其余情况无需转换字节序,收发数据的字节序转换是自动的

3.4 网络地址的初始化与分配

将字符串信息转化为网络字节序的整数型

inet_addr将点分十进制(Dotted Decimal Notation)的字符串形式IP地址转换成32位整型数据,它在转换的同时进行网络字节序的转换
注意:目前已知inet_addr在 windows 中被弃用,使用inet_pton,其用处和使用方式和下面说到的inet_aton一样

1
2
3
#include <arpa/inet.h>

in_addr_t inet_addr(const char* string); // S:32位大端序整数类型值 F:INADDR_NONE

inet_aton的使用频率更高,它将字符串形式IP转换为网络字节序整型IP地址,保存地址使用了in_addr结构体

1
2
3
4
5
#include <arpa/inet.h>

// string 点分十进制形式字符串IP地址
// addr 将转换结果保存到这里
int inet_aton(const char* string, struct in_addr* addr); // S:1(true) F:0(false)

inet_ntoa与上面的作用正好相反,将网络字节序整型IP地址转换为字符串形式IP地址

1
2
3
#include <arpa/inet.h>

char* inet_ntoa(struct in_addr adr); // S:字符串地址 F:-1

注意此函数不要求我们分配空间,所以我们拿到字符串地址之后应该strcpy到自己能掌握的空间,因为inet_ntoa再一次调用可能就会覆盖之前的空间

网络地址初始化
1
2
3
4
5
6
struct sockaddr_in addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
//sin_addr.s_addr = inet_addr(argv[1]);
sin_addr.s_addr = htonl(INADDR_ANY);
sin_port = htons(atoi(argv[1]));

服务器端和客户端的连接需求不同,使用的函数也不同

服务器:请把进入IP 211.217.168.13、9190端口的数据给我,使用bind函数
客户端:请连接到IP 211.217.168.13、9190端口,使用connect函数

INADDR_ANY

上面的代码中我们用到了

1
sin_addr.s_addr = htonl(INADDR_ANY);

一般在服务端会这样写,表示监听本机所有可用的网络 IP 地址,如果存在多个IP地址,只要端口号正确便可以正确的收发数据;在客户端一般不会这样写

并不是一张 NIC 就只有一个 IP,有时你可能只想用一个确定的 IP 通信,就要手动分配,如果只有一个 IP 或者只要能通信就行,直接使用INADDR_ANY即可

向套接字分配网络地址
1
2
3
4
5
6
7
#include <sys/socket.h>

// sockfd 套接字文件描述符
// myaddr 存有地址信息的结构体变量
// addrlen 第二个结构体变量长度

int bind(int sockfd, struct sockaddr* myaddr, socklen_t addrlen); // S:0 F:-1
Windows和Linux使用的函数

上述函数均为Linux下的函数,但是windows也可使用,除了inet_addr,它在windows中已被弃用,取而代之的是inet_pton(实际上它就是新函数,以后尽可能用它)

除此之外,windows下还有两个专用函数WSAAddressToStringWSAStringToAddress,它们和inet_ntoa以及inet_addr功能一样,只是了解一下,用它们的话会降低程序的可移植性和兼容性,说这话的意思就是能不用就不用

Chapter4 基于TCP的服务器端/客户端(1)

4.1 理解TCP和UDP

TCP是Transmission Control Protocol,传输控制协议,意为对数据传输过程的控制

这篇文章简单讲述了OSI七层网络模型:OSI七层网络模型

不过我们只探讨四层(对编程来讲足矣)

TCPIPvsOSI

TCP/IP协议栈

数据传输会根据我们选择的协议而走TCP层或UDP层

TCP/IP协议栈

链路层

计算机间通信通过设备(路由器、交换机等)进行物理连接的标准

TCP/UDP层

IP层解决的是数据传输过程中的路径选择问题,但仅靠IP层传输数据不可靠(无序,丢失等等),因此在其上加了一个传输层(TCP层+UDP层),现在只简单讲解TCP,UDP后面再说,TCP协议规定了确认机制超时重传,即A给B发送数据,B在收到一个数据包之后就要给A反馈已收到,要是A没有收到确认就会重传该数据包(这里讲的太简单了,实际上内容不少的)

应用层

到这里就是我们熟悉的套接字编程了,上面讲的那些都已经向开发人员隐藏起来了,我们进行套接字编程无需关注那些细枝末节的问题

4.2 实现基于TCP的服务器端/客户端

TCP服务器端默认的函数调用顺序

函数调用顺序

进入等待连接请求状态
1
2
3
4
5
#include <sys/socket.h>

// sock 将进入监听状态的套接字
// backlog 连接请求队列(Queue)的长度,若为5,队列长度为5,表示最多使5个连接请求进入队列
int listen(int sock, int backlog); // S:0 F:-1

sock像是门卫,接待客人(连接请求),如果有正在受理的事物就让他们在等候室等着,backlog是等候室的大小

必须服务端listen之后客户端connect才会产生数据交互

受理客户端请求

listen之后就要准备按序受理请求了,受理请求也是利用套接字进行的,但不是服务器套接字,毕竟人家都是门卫了,所以我们要创建一个新的套接字受理请求,用accept便可创建

1
2
3
4
5
6
#include <sys/socket.h>

// sock 服务器端的套接字文件描述符
// addr 保存发起连接请求的客户端地址信息的变量地址
// addrlen 第二个参数addr结构体的长度
int accept(int sock, struct sockaddr* addr, socklen_t* addrlen); // S:返回创建的套接字文件描述符 F:-1

调用accept函数受理连接请求时服务器端进入阻塞(blocking)状态,这时候来的连接请求都会被加入等待队列

这样,连接请求相当于是插头,服务器套接字和accept生成的套接字共同组成一个完整的插座(插座的两个孔),连接请求插到这两部分socket组成的完整插座便可正常通信了(这样比喻也许不太合理,毕竟服务器socket充当的是“门卫”的角色)

TCP客户器端默认的函数调用顺序

客户端函数调用顺序

发送连接请求是通过connect函数完成的

1
2
3
4
5
#include <sys/socket.h>
// sock 客户端套接字文件描述符
// servaddr 保存目标服务器端地址信息的变量地址值
// addrlen 第二个参数servaddr的长度
int connect(int sock, struct sockaddr* servaddr, socklen_t addrlen); // S:0 F:-1

客户端调用connect之后,发生以下情况之一才会返回(完成函数调用)

  • 服务器端接收连接请求
  • 发生断网等异常情况而中断连接请求

服务器端“接收连接请求”并不是调用accept函数,而是指把客户端连接请求的信息加入等待队列;而调用accept函数是“受理连接请求”,要区别开“接收”和“受理”

我们创建客户端套接字的时候并没有手动分配IP和端口,但只要是网络数据交换,就必须分配IP和端口,那么客户端套接字是何时、何地、如何分配地址的呢?

  • 何时?调用connect函数时
  • 何地?操作系统内核
  • 如何?IP用主机IP,端口随机

客户端的IP和端口在调用connect函数时自动分配,端口随机

4.3 实现迭代服务器端/客户端

接下来要实现回声(echo)服务器端/客户端,服务器端将客户端传送的字符串数据原封不动地传回客户端,在此之前先要了解一下迭代服务器

实现迭代服务器端

之前我们写的小程序在完成一次数据传输之后,服务端和客户端就终止了,但是现实情况是服务端不该终止,要能源源不断处理客户端的连接请求,而客户端在运行期间也不该是发送一次请求就终止的,其实我们加个循环就能解决这个问题了

迭代服务器端

调用上图的close(client)就意味着结束了对某个客户端的服务,但这个程序在某一时刻只能针对单一个客户端提供服务,以后学完进程和线程之后就能做到同时服务多个客户端了

迭代回声服务器端/客户端
  • 服务器端在同一时刻只与一个客户端连接,并提供回声服务
  • 服务器端依次向五个客户端提供服务并退出(指五次connect,并不是一次连接请求收发五条信息)
  • 客户端接收用户收到的字符串并发送到服务器端
  • 服务器端将接收的字符串数据传回客户端
  • 服务器端与客户端之间的数据传输一直到用户输入Q为止

服务端

{.line-numbers}
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
// server_echo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(char* message);

int main(int argc, char** argv) {
int serv_sock, clnt_sock;
char message[BUF_SIZE];
int str_len, i;
struct sockaddr_in serv_addr, clnt_addr;
socklen_t clnt_addr_size;

if (argc != 2) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}

serv_sock = socket(PF_INET, SOCK_STREAM, 0);
if (serv_sock == -1)
error_handling("socket() error!");

memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = PF_INET;
serv_addr.sin_port = htons(atoi(argv[1]));
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);

if (bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("bind() error!");

if (listen(serv_sock, 5) == -1)
error_handling("listen() error!");

clnt_addr_size = sizeof(clnt_addr);

for (i = 0; i < 5; i++) {
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_addr, &clnt_addr_size);
if (clnt_sock == -1)
error_handling("accept() error!");
else
printf("Connected client %d\n", i+1);

while( (str_len = read(clnt_sock, message, BUF_SIZE)) != 0 )
write(clnt_sock, message, str_len);

close(clnt_sock);
}

close(serv_sock);

return 0;
}

void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

客户端

{.line-numbers}
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
// client_echo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h> // write read 声明的地方
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(char* message);

int main(int argc, char** argv) {
int clnt_sock;
char message[BUF_SIZE];
int str_len;
struct sockaddr_in serv_addr;

if (argc != 3) {
printf("Usage: %s <ip> <port>\n", argv[0]);
exit(1);
}

clnt_sock = socket(PF_INET, SOCK_STREAM, 0);
if (clnt_sock == -1)
error_handling("socket() error!");

memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = PF_INET;
serv_addr.sin_port = htons(atoi(argv[2]));
if (inet_aton(argv[1], &serv_addr.sin_addr) == 0) // 会自动转换字节序
error_handling("inet_aton() error!");

// 建立连接
if (connect(clnt_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("connect() error!");
else
puts("Connected........");

while(1) {
fputs("Input message(Q to quit): ", stdout);
fgets(message, BUF_SIZE, stdin);

if (!strcmp(message, "q\n") || !strcmp(message, "Q\n"))
break;

write(clnt_sock, message, strlen(message)); // 通过clnt_sock发送数据到服务端
str_len = read(clnt_sock, message, BUF_SIZE-1); // 通过clnt_sock从服务端接收数据
message[str_len] = 0;
printf("Message from server: %s", message);
}

close(clnt_sock);
return 0;
}

void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
echo客户端存在的问题

上面的代码中 line 45~48

1
2
3
4
write(clnt_sock, message, strlen(message));
str_len = read(clnt_sock, message, BUF_SIZE-1);
message[str_len] = 0;
printf("Message from server: %s", message);

因为TCP是面向连接的传输方式,不存在数据边界(服务端和客户端调用read/write无需一一对应),这样如果服务端那边想要传输的数据太大,可能会分多次发送,客户端这边可能就会在尚未接收到所有数据就调用read函数,接下来就完美实现这个客户端

Chapter5 基于TCP的服务器端/客户端(2)

5.1 echo客户端的完美实现

为什么说是客户端的问题呢?因为数据多大就需要分批次传输不是服务器端程序能决定的(大概),它已经做好了它该做的了,也就是回传信息,关键在于客户端想一口一头猪,想一次把信息全部接收,殊不知服务器那边可能会分批传输数据(TCP面向连接传输),所以问题出在客户端而不是服务器端

而解决方法也很简单,在客户端接收服务器传回的数据之前计算好应该接收的字符串长度,长度不够就一直read,直到长度够了,说明数据完整了,这时就能继续接下来的事情了

修改上面客户端的程序

{.line-numbers}
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
// client_echo2.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h> // write read 声明的地方
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(char* message);

int main(int argc, char** argv) {
int clnt_sock;
char message[BUF_SIZE];
int str_len, recv_total_len, recv_one_len;
struct sockaddr_in serv_addr;

if (argc != 3) {
printf("Usage: %s <ip> <port>\n", argv[0]);
exit(1);
}

clnt_sock = socket(PF_INET, SOCK_STREAM, 0);
if (clnt_sock == -1)
error_handling("socket() error!");

memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = PF_INET;
serv_addr.sin_port = htons(atoi(argv[2]));
if (inet_aton(argv[1], &serv_addr.sin_addr) == 0) // 会自动转换字节序
error_handling("inet_aton() error!");

// 建立连接
if (connect(clnt_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("connect() error!");
else
puts("Connected........");

while(1) {
fputs("Input message(Q to quit): ", stdout);
fgets(message, BUF_SIZE, stdin);

if (!strcmp(message, "q\n") || !strcmp(message, "Q\n"))
break;

str_len = write(clnt_sock, message, strlen(message)); // 通过clnt_sock发送数据到服务端
recv_total_len = 0;
while (recv_total_len < str_len) {
recv_one_len = read(clnt_sock, message + recv_total_len, BUF_SIZE-1); // 通过clnt_sock从服务端接收数据
if (recv_one_len == -1)
error_handling("read() error!");
recv_total_len += recv_one_len;
}
message[str_len] = 0;
printf("Message from server: %s", message);
}

close(clnt_sock);
return 0;
}

void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

line 46~52,解决了上面的问题

如果问题不在客户端,定义引用层协议

上面解决问题的前提是我们知道了接收数据的大小,这显然在现实中不大可能,所以,我们可以在应用层制定一些协议,即一些收发数据的规则,比如数据大小,发送格式等等

这里收发数据用到的write/read(Windows下是send/recv)要注意缓冲区刷新,字节边界的问题,我暂时不想深究这些细节,先继续学习

作者在代码里写了一个挺有意思的东西,用char数组存储int整数,因为输入是按照%d进行的,所以可存储的数据是4字节的,至于为什么这么做,毕竟Socket只能收发字节数组,具体我也不太清楚,要说原因也只能想到和字节序转换有关,也许这就是神圣的字节流吧

1
2
3
4
5
6
#define SZ 4
char* arr[1024];

for (int i = 0; i < count; i++) {
scanf("%d", (int*)&arr[i*SZ]);
}

5.2 Windows echo客户端实现

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
// client_echo2_win.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <winsock2.h>

#define BUF_SIZE 1024

void error_handling(char* message);

int main(int argc, char** argv) {
SOCKET clnt_sock;
char message[BUF_SIZE];
int str_len, recv_total_len, recv_one_len;
struct sockaddr_in serv_addr;

if (argc != 3) {
printf("Usage: %s <ip> <port>\n", argv[0]);
exit(1);
}

WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
error_handling("WSAStartup() error!");

clnt_sock = socket(PF_INET, SOCK_STREAM, 0);
if (clnt_sock == INVALID_SOCKET)
error_handling("socket() error!");

memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(atoi(argv[2]));
// Windows 无 inet_aton,替换为 inet_addr(功能一致)
serv_addr.sin_addr.s_addr = inet_addr(argv[1]);
if (serv_addr.sin_addr.s_addr == INADDR_NONE)
error_handling("inet_addr() error!");

// 建立连接(函数名与 Linux 一致)
if (connect(clnt_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == SOCKET_ERROR)
error_handling("connect() error!");
else
puts("Connected........");

while (1) {
fputs("Input message(Q to quit): ", stdout);
fgets(message, BUF_SIZE, stdin);

if (!strcmp(message, "q\n") || !strcmp(message, "Q\n"))
break;

str_len = strlen(message);
send(clnt_sock, message, str_len, 0);

recv_total_len = 0;
while (recv_total_len < str_len) {
recv_one_len = recv(clnt_sock, message + recv_total_len, BUF_SIZE - 1, 0);
if (recv_one_len == SOCKET_ERROR)
error_handling("recv() error!");
recv_total_len += recv_one_len;
}

message[str_len] = 0;
printf("Message from server: %s", message);
}

closesocket(clnt_sock);
WSACleanup();
return 0;
}

void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

5.3 TCP原理

TCP套接字中的I/O缓冲

TCP套接字数据收发无边界,即服务端write和客户端read不需要一一对应,因为在调用它们时,数据会移至缓冲区(输出缓冲和输入缓冲)

I/O缓冲的特性如下

  • I/O缓冲在每个TCP套接字中单独存在
  • I/O缓冲在创建套接字时自动生成
  • 即使关闭套接字也会继续输出套接字中的遗留数据
  • 关闭套接字将丢失输入缓冲中的数据

在TCP传输中不会发生超过输入缓冲大小的数据传输,因为TCP会控制数据流,TCP中有滑动窗口(Sliding Window)协议,它的工作原理大概是同发送端进行对话,告诉它接收端最多可以接收多少数据,那么服务端就知道自己该发送多少数据了

TCP内部工作原理

TCP通信分为三步

  1. 建立连接
  2. 进行数据交互
  3. 断开连接

每一步都由不同的过程进行

建立连接是通过 三次握手(Three-way Handshark) 进行的。套接字是以全双工(Full-duplx)方式工作的,意思是可以双向传输数据

(1) 建立连接时,首先A向B发送

1
[SYN] SEQ: 1000, ACK: -

SYN表示进行数据交互前的同步消息,其中SEQ(序列号)为1000,ACK(确认消息)为空

SEQ为1000指:现在传输的数据包序列号为1000,确认无误后通知我发送1001号数据包

(2) 然后B向A发送

1
[SYN+ACK] SEQ: 2000, ACK: 1001

SYN+ACK表示这既是一条用于建立连接的同步消息,又是一条确认消息

SEQ为2000指:现在传输的数据包序列号为2000,确认无误后通知我发送2001号数据包

ACK为1001指:刚才你传输的1000号数据包接收无误,请发送1001号数据包

收发数据前向向数据包分配序号,并向对方通报序号,以便丢包重传

(3) 最后A向B发送

1
[SYN] SEQ: 1001 ACK: 2001

通过此条消息确认之后A和B便可建立连接,这个过程也成称为三次握手

进行数据交互也像上面那样互相传输数据,只不过服务端只需考虑怎么样让发送端知道接收端收到的数据丢失了,并且发送端要从丢失的数据那里开始继续传输,解决这些问题只要修改一下ACK的计算公式就好了

1
ACK = SEQ + 传递成功的字节数 + 1

这样就能很方便地进行失败重传了,因为确认号会告诉发送端下一次从这个序号的数据开始传输

断开连接是通过 四次挥手(Four-way Wave) 完成的,在这过程中A请求断开连接(包含FIN),然后B会发送一条确认消息给A,A不会回应,B触发超时重传(这条重传消息中包含FIN)告诉A它也可以断开连接了,然后A发送确认消息确认断开连接,这个过程有四次数据传送

四次握手

Chapter6 基于UDP的服务器端/客户端

IP 协议是无连接、不可靠的底层传输;TCP 基于 IP 封装了确认应答、丢包重传(保障可靠性)、流量控制(匹配收发速率)与拥塞控制(规避网络拥堵),适合文件传输等要求数据完整的场景。UDP 不提供重传与可靠保障,协议开销小、转发延迟低,因此直播、实时语音、游戏对战等优先低延迟、可容忍少量丢包的实时业务,通常采用 UDP

TCP比UDP慢的原因在于:

  • 收发数据前后进行的连接设置及清除过程
  • 收发数据过程中为保证可靠性而添加的流控制

UDP中服务器端和客户端没有连接,两端均只需一个套接字传输数据,使用UDP进行数据传输需要每次都添加目标地址信息(bind函数),这相当于每次写信都要填写地址,UDP套接字相当于邮箱,UDP数据传输就像写好信丢到邮箱,他就能去往信上的目标地址,虽然不一定能到达,但大部分情况下还是可靠的

数据传输主要利用下面两个函数

1
2
3
4
5
6
7
8
9
10
#include <sys/socket.h>

// sock 用于传输数据的套接字fd
// buff 保存待传输数据缓冲的地址
// nbytes buff的字节大小
// flags 可选参数,没有则为0
// to 目标地址
// addrlen to的大小
ssizt_t sendto(int socket, void* buff, size_t nbytes, int flags,
struct sockaddr* to, socklen_t addrlen); // S: 成功传输的字节数 F: -1
1
2
3
4
5
6
7
8
9
10
#include <sys/socket.h>

// sock 用于接收数据的套接字fd
// buff 保存接收数据缓冲的地址
// nbytes buff的字节大小
// flags 可选参数,没有则为0
// from 目标地址
// addrlen to的大小
ssizt_t recvfrom(int socket, void* buff, size_t nbytes, int flags,
struct sockaddr* from, socklen_t addrlen); // S: 成功接收的字节数 F: -1

因为 UDP没有请求连接和受理连接(没有connect、listen和accept) 的过程,所以严格意义上来说没有办法区分服务器端和客户端,在这里只能将提供服务的称为服务器端

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
// udp_server_echo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 32
void error_handling(char* message);

int main(int argc, char** argv) {
int serv_sock;
char message[BUF_SIZE];
int str_len;
struct sockaddr_in serv_addr, clnt_addr;
socklen_t clnt_addr_size;

if (argc != 2) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}

serv_sock = socket(PF_INET, SOCK_DGRAM, 0);
if (serv_sock == -1)
error_handling("UDP socket creation error!");

memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = PF_INET;
serv_addr.sin_port = htons(atoi(argv[1]));
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);

if (bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("bind() error!");

while (1) {
clnt_addr_size = sizeof(clnt_addr);
str_len = recvfrom(serv_sock, message, BUF_SIZE, 0,
(struct sockaddr*)&clnt_addr, &clnt_addr_size);
sendto(serv_sock, message, str_len, 0,
(struct sockaddr*)&clnt_addr, clnt_addr_size);
}

close(serv_sock);
return 0;
}

void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(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
// udp_client_echo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h> // write read 声明的地方
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 32
void error_handling(char* message);

int main(int argc, char** argv) {
int clnt_sock;
char message[BUF_SIZE];
int str_len;
socklen_t addr_size;
struct sockaddr_in serv_addr, from_addr;

if (argc != 3) {
printf("Usage: %s <ip> <port>\n", argv[0]);
exit(1);
}

clnt_sock = socket(PF_INET, SOCK_DGRAM, 0);
if (clnt_sock == -1)
error_handling("socket() error!");

memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = PF_INET;
serv_addr.sin_port = htons(atoi(argv[2]));
if (inet_aton(argv[1], &serv_addr.sin_addr) == 0) // 会自动转换字节序
error_handling("inet_aton() error!");

while(1) {
fputs("Insert message(q to quit): ", stdout);
fgets(message, sizeof(message), stdin);
if (!strcmp(message, "q\n") || !strcmp(message, "Q\n")) break;
sendto (clnt_sock, message, strlen(message), 0,
(struct sockaddr*)&serv_addr, sizeof(serv_addr));
addr_size = sizeof(serv_addr);
str_len = recvfrom(clnt_sock, message, BUF_SIZE, 0,
(struct sockaddr*)&from_addr, &addr_size);
message[str_len] = 0;
printf("Message from server: %s\n", message);
}
close(clnt_sock);
return 0;
}

void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

调用bind()函数可以给套接字绑定地址信息,未绑定的话调用sendto()函数时会将本机ip分配给套接字,也分配一个随机端口号

UDP传输是存在数据边界的,服务端和客户端的IO函数必须一一对应,数量一致

UDP套接字调用sendto函数传输数据有三个阶段:

  1. 向UDP套接字注册目标IP和端口号
  2. 传输数据
  3. 删除UDP套接字中注册的目标地址信息

每次都变更目标地址,因此可以使用同一个UDP套接字向不同的目标传输数据,这种未注册目标地址的套接字称为未连接套接字,反之则是connected套接字,UDP默认属于未连接套接字,如果使用UDP套接字长时间与一套主机通信,那么应该将它变为connected套接字,不然每次传输数据都要进行上面三个过程,很浪费时间

UDP套接字绑定默认目标(简化发送),只需调用connect向套接字注册目标 IP 和端口;UDP 永远不存在「创建连接」这个行为!

1
2
3
4
5
6
sock = socket(PF_INET, SOCK_DGRAM, 0);
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = ...
addr.sin_port = ...
connect(sock, (struct sockaddr*)&addr, sizeof(addr));

针对UDP套接字调用connect函数并不意味着与另一个套接字建立连接,这只是向UDP套接字注册目标IP和端口信息

但其实完全没必要这么做,我们填好address之后第一次sendto内核就自动帮我们绑定目标地址了

之后就可以调用sendto/recvfrom、write/read收发数据(已经注册了目标IP和端口号)

在Windows上UDP用的函数和Linux上的类似

1
2
3
4
5
#include <winsock2.h>

int sendto(SOCKET s, const char* buf, int len, int flags,
const struct sockaddr* to, int tolen);
// S: 成功传输的字节数 F: SOCKET_ERROR
1
2
3
4
5
#include <winsock2.h>

int rrecvfrom(SOCKET s, const char* buf, int len, int flags,
const struct sockaddr* from, int fromlen);
// S: 成功传输的字节数 F: SOCKET_ERROR

Chapter7 优雅地断开套接字连接

之前的代码里最后都是直接调用closeclosesocket关闭套接字,这种是完全断开连接,无法进行数据IO,就连IO缓冲里面的数据也会销毁,无法接收或传输,这样可能会造成数据丢失,为了避免这种情况,我们可以使用半关闭(Half-close),只关闭一部分数据交换中的流,让套接字只能接收数据或只能发送数据

两个套接字建立连接相当于打通管道,数据流相当于水流,只能朝一个方向移动,建立连接后每台主机都拥有两个流,一个输入流,一个输出流,半关闭就是关闭这两个流其中的一个

“流”是套接字建立连接形成的概念,所以只有TCP中存在流,UDP中没有

1
2
3
4
5
#include <sys/socket.h>

// sock 需要断开的套接字fd
// howto 传递断开方式信息
int shutdown(int sock, int howto); // S: 0 F: -1

howto的三个值:SHUT_RD、SHUT_WR和SHUT_RDWR,关闭输入流,输入缓冲中有数据也会被抹去,关闭输出流,输出缓冲中的数据会继续传递至目标主机

Windows下与Linux下半关闭相似

1
2
3
#include <winsock2.h>

int shutdown(SOCKET sock, int howto);

howto的三个值: SD_RECEIVE、SD_SEND和SD_BOTH

下面是一个传输文件的demo

Linux服务端,将名称为hello.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
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
// linux_FileTransmitServer.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>

#define BUF_SIZE 64
#define MULTI_CLIENT 1

void error_handling(const char* msg);

int main(int argc, char** argv) {
const char filename[] = "hello.txt";
FILE* fp;
char buf[BUF_SIZE] = {0};
char message[32] = {0};
long recv_len = 0;
int serv_sock, clnt_sock;
struct sockaddr_in serv_addr, clnt_addr;
socklen_t clnt_addr_size = 0;
size_t read_count = 0, file_size = 0;

// check input
if (argc < 2) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}
printf("-------- Start ----\n");

// open file by "rb"
fp = fopen(filename, "rb");
if (!fp)
error_handling("fopen() error");

// init sock, addr
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
if (serv_sock == -1)
error_handling("socket() error!");

memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(atoi(argv[1])); // short
//serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (inet_aton("127.0.0.1", &serv_addr.sin_addr) == 0)
error_handling("inet_aton() error!");

// host info
printf("HIOST IP: %s\n", inet_ntoa(serv_addr.sin_addr));

// bind addr, listen request, accept to get clnt_sock
if (bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("bind() error!");

if (listen(serv_sock, 5) == -1)
error_handling("listen() error!");

clnt_addr_size = sizeof(clnt_addr);

#if MULTI_CLIENT
char* clnt_ip[3] = {0};
uint8_t ips = 0;
for (int client_i = 0; client_i < 3; client_i++)
{
#endif
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_addr, &clnt_addr_size);
if (clnt_sock == -1)
error_handling("accept() error!");

// print client info
printf("IP %s connected.\n", inet_ntoa(clnt_addr.sin_addr));

#if MULTI_CLIENT
for (int i = 0; i < ips; i++) {
if ( strcmp(clnt_ip[i], inet_ntoa(clnt_addr.sin_addr)) == 0 ) {
printf("FORBIDDEN_SAME\n");
write(clnt_sock, "FORBIDDEN_SAME", 14);
}
}
clnt_ip[client_i] = inet_ntoa(clnt_addr.sin_addr);
ips++;
#endif

// transmission, calculate file size
printf("len filename: %lu\n", sizeof(filename));
write (clnt_sock, (void*)filename, sizeof(filename));
while (1) {
read_count = fread(buf, 1, BUF_SIZE, fp);
file_size = file_size + read_count;
if (read_count < BUF_SIZE) {
write(clnt_sock, buf, read_count);
break;
}
write(clnt_sock, buf, BUF_SIZE);
}
printf("File data transmis done.\n");

// attempt to use dialogue to indicate that you want to transmit another message
recv_len = read(clnt_sock, message, sizeof(message)-1);
printf("File info: %s\n", message);
if (recv_len == -1)
error_handling("read() error!");

if (!strcmp(message, "FILE_SIZE")) {
sprintf(message, "%ld", file_size);
write(clnt_sock, message, sizeof(file_size));
}

// half-close transmission stream, serv_sock still could receive data
shutdown(serv_sock, SHUT_WR);

// receive feedback message by clnt_sock
recv_len = read(clnt_sock, message, sizeof(message)-1);
if (recv_len == -1)
error_handling("read() error!");
else
printf("Message from client: %s\n", message);

#if MULTI_CLIENT
}
#endif
// close file and socks completely
fclose(fp);
close(clnt_sock);
close(serv_sock);
return 0;
}

void error_handling(const char* msg) {
fputs(msg, stderr);
fputc('!', stderr);
fputc('\n', stderr);
exit(1);
}

Windows客户端

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
// windows_FileTransmitClient.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <winsock2.h>
#include <ws2tcpip.h> // inet_pton

#define BUF_SIZE 64

void error_handling(const char* msg);

int main(int argc, char** argv) {
WSADATA wsaData;
SOCKET clnt_sock;
SOCKADDR_IN serv_addr;
FILE* fp = NULL;
char message[32] = {0};
char buf[BUF_SIZE] = { 0 };
int recv_len;

// check input
//if (argc < 3) {
// printf("Usage: %s <ip> <port>", argv[0]);
//}

// init windows env, socket, serv_addr
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
error_handling("WSAStarup() error!");

clnt_sock = socket(PF_INET, SOCK_STREAM, 0);
if (clnt_sock == SOCKET_ERROR)
error_handling("socket() error!");

memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
//inet_pton(AF_INET, argv[1], &serv_addr.sin_addr.S_un.S_addr);
inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr.S_un.S_addr);
//serv_addr.sin_port = htons(atoi(argv[2]));
serv_addr.sin_port = htons(9098);

// connect
if ( connect(clnt_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == SOCKET_ERROR )
error_handling("connect() error!");

// receive file name and data, use the name to create file
recv_len = recv(clnt_sock, message, sizeof(message) - 1, 0);
if (recv_len == SOCKET_ERROR)
error_handling("recv() error!");

if (strcmp(message, "FORBIDDEN_SAME") == 0) {
printf("又是你小子,滚蛋\n");
exit(0);
}

printf("File name: %s\n", message);

errno_t err = fopen_s(&fp, message, "wb");
if (err != 0)
error_handling("fopen_s() error!");

while (1) {
recv_len = recv(clnt_sock, buf, BUF_SIZE, 0);
if (recv_len < BUF_SIZE) {
fwrite(buf, 1, recv_len, fp);
break;
}
fwrite(buf, 1, BUF_SIZE, fp);
}
printf("Receive data done.\n");

// send "FILE_SIZE" to server for requesting file size
send(clnt_sock, "FILE_SIZE", 9, 0);
recv(clnt_sock, message, sizeof(message) - 1, 0);
printf("File size: %s", message);

// send terminator message
send(clnt_sock, "Thank you!", 10, 0);

// close file pointer and socket completely
fclose(fp);
closesocket(clnt_sock);
WSACleanup();

return 0;
}

void error_handling(const char* msg) {
fputs(msg, stderr);
fputc('\n', stderr);
exit(1);
}

Chapter8 域名及网络系统

DNS(Domain Name System,域名系统)是IP地址和域名进行转换的系统,核心是DNS服务器。DNS的工作系统是一种层次化管理的分布式系统,本地转换未知域名时,局域网DNS服务器会逐级向上请求到根DNS服务器,然后由根DNS服务器逐级向下请求从而找到要转换的域名

在网络编程中使用域名是很有必要的,因为IP地址可能会变更,甚至经常变更,但是域名几乎不会改变,可以说一个域名会跟随一个网站(相同的服务,是服务不是服务器)一生

利用域名获取IP地址

1
2
3
#include <netdb.h>

struct hostent* gethostbyname(const char* hostname); // S: hostent结构体 F: NULL

返回的结构体存储了域名和IP,所以只利用这个结构体我们就能获得域名和IP的信息

1
2
3
4
5
6
7
8
struct hostent  
{
char *h_name; // official name
char **h_aliases; // alias list
int h_addrtype; // host address type, IPV4-AF_INET
int h_length; // address length
char **h_addr_list; // address list
};
  • h_name - 官方域名(Official domain name),但并不一定有
  • h_aliases - 一个IP可以绑定多个域名,这里存储的是可以访问同一IP的所有域名
  • h_addrtype - 此函数支持IPv4和IPv6,这个值就指示使用的IP版本,是IPv4的话其值等于AF_INET
  • h_length - IP地址长度,单位为字节,IPv4值为4,IPv6值为16
  • h_addr_list - 此变量以整数形式保存域名对应的IP地址,有些网站用户数大,可能会将多个IP与一个域名绑定,利用多台服务器进行负载均衡

hostent结构体

利用下面这段代码测试gethostbyname

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
// getip.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <netdb.h>
void error_handling(char* msg);

int main(int argc, char** argv) {
struct hostent* host;
size_t i;

if (argc != 2) {
printf("Usage: %s <domain>\n", argv[0]);
exit(1);
}

host = gethostbyname(argv[1]);
if (!host)
error_handling("gethostbyname() error!");

printf("Official name: %s\n", host->h_name);
for (i = 0; host->h_aliases[i]; i++)
printf("Alias[%lu]: %s\n", i, host->h_aliases[i]);
printf("Address type: %s\n",
host->h_addrtype == AF_INET ? "AF_INET" : "AF_INET6");
printf("Address length: %d\n", host->h_length);
for (i = 0; host->h_addr_list[i]; i++)
printf("IP[%lu]: %s\n",
i, inet_ntoa(*(struct in_addr*)host->h_addr_list[i]));
printf("net IP: %s\n", host->h_addr_list[0]);
return 0;
}

void error_handling(char* msg) {
fputs(msg, stderr);
fputc('\n', stderr);
exit(1);
}

利用IP获取域名

1
2
#include <netdb.h>
struct hostent* gethostbyaddr(const char* addr, socklen_t len, int family);

addr参数实际上不是char*类型,而是一个指向存放IPv4地址的某个in_addr结构的指针,以前没有void*这种东西,后来的标准里这种不定类型的指针都用void*代替了

下面的程序是正确的,使用127.0.0.1便可测试,但是使用外网IP不一定能成功,需要在/etc/hosts文件里写上ip和域名对应关系才能解析,毕竟应该没有人用一台域名服务器在学习

详情请见:无脑仔的小明的博客–gethostbyaddr函数

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
// getdomain.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netdb.h>
void error_handling(const char* msg);

int main(int argc, char** argv) {
struct hostent* host;
struct sockaddr_in addr;
size_t i;

if (argc != 2) {
printf("Usage: %s <addr>\n", argv[0]);
exit(1);
}

memset(&addr, 0, sizeof(addr));
inet_aton(argv[1], (struct in_addr*)&addr.sin_addr);

host = gethostbyaddr((void*)&addr.sin_addr, 4, AF_INET);
if (!host)
error_handling("gethostbyaddr() error!");

printf("Official name: %s\n", host->h_name);
for (i = 0; host->h_aliases[i]; i++)
printf("Alias[%lu]: %s\n", i, host->h_aliases[i]);
printf("Address type: %s\n",
host->h_addrtype == AF_INET ? "AF_INET" : "AF_INET6");
printf("Address length: %d\n", host->h_length);
for (i = 0; host->h_addr_list[i]; i++)
printf("IP[%lu]: %s\n",
i, inet_ntoa(*(struct in_addr*)host->h_addr_list[i]));

return 0;
}

void error_handling(const char* msg) {
fputs(msg, stderr);
fputc('\n', stderr);
exit(1);
}

Windows上也有类似的函数

1
2
3
#include <winsock2.h>

struct hostent* gethostbyname(const char* name); // S: address of hostent F: NULL
1
2
3
#include  <winsock2.h>

struct hostent* gethostbyaddr(const char* addr, int len, int type);

Chapter9 套接字的多种可选项

套接字有多种特性,这些特性可以通过设置可选项更改(Linux和Windows几乎一样)
(注:实际可设置的可选项比下表多得多)

Options

  • SOL_SOCKET:套接字相关通用设置
  • IPPROTO_TCP:TCP协议相关
  • IPPROTO_IP:IP协议相关

get/set 套接字特性

1
2
3
4
5
6
7
8
9
#include <sys/socket.h>

// 查看套接字可选项
// sock 要查看的套接字
// level 可选项的协议层
// optname 可选项名
// optval 保存结果缓冲地址的变量
// optlen optval的大小,调用此get方法之后它变为缓冲内保存的字节数,set不会这样
int getsockopt(int sock, int level, int optname, void* optval, socklen_t* optlen); // S: 0 F: -1
1
2
3
4
#include <sys/socket.h>

// 设置套接字可选项
int setsockopt(int sock, int level, int optname, const void* optval, socklen_t optlen); //S: 0 F: -1

套接字类型只能在创建时决定,之后不可更改
验证套接字类型:协议层为SOL_SOCKET,选项名为SO_TYPE(典型的只读可选项)

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
// getype.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>

void error_handling(const char* msg);

int main(int argc, char** argv) {
int sock_tcp, sock_udp;
int sock_type;
socklen_t optlen;
int state;

optlen = sizeof(sock_type);
sock_tcp = socket(PF_INET, SOCK_STREAM, 0);
sock_udp = socket(PF_INET, SOCK_DGRAM, 0);
printf("SOCK_STREAM: %d\n", SOCK_STREAM);
printf("SOCK_DGRAM: %d\n", SOCK_DGRAM);

state = getsockopt(sock_tcp, SOL_SOCKET, SO_TYPE, (void*)&sock_type, &optlen);
if (state)
error_handling("getsockopt() error!");
printf("Socket type of sock_tcp: %d \n", sock_type);

state = getsockopt(sock_udp, SOL_SOCKET, SO_TYPE, (void*)&sock_type, &optlen);
if (state)
error_handling("getsockopt() error!");
printf("Socket type of sock_udp: %d \n", sock_type);

return 0;
}

void error_handling(const char* msg) {
fputs(msg, stderr);
fputc('\n', stderr);
exit(1);
}

运行结果

1
2
3
4
SOCK_STREAM: 1
SOCK_DGRAM: 2
Socket type of sock_tcp: 1
Socket type of sock_udp: 2

控制I/O缓冲大小

第五章说过创建套接字的同时会生成I/O缓冲,而这个缓冲区大小是可以设置的

使用选项 SO_SNDBUFSO_RCVBUF 设置

  • SO_SNDBUF:输出缓冲区大小设置/读取
  • SO_RCVBUF:输入缓冲区大小设置/读取

读取一下前面写的那么些个程序中创建的默认tcp 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
// get_buf.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>

void error_handling(const char* msg);

int main(int argc, char** argv) {
int send_buf, recv_buf, state;
int sock_tcp;
socklen_t len;

sock_tcp = socket(PF_INET, SOCK_STREAM, 0);
len = sizeof(send_buf);
state = getsockopt(sock_tcp, SOL_SOCKET, SO_SNDBUF, (void*)&send_buf, &len);
if (state)
error_handling("getsockopt() error!");

len = sizeof(recv_buf);
state = getsockopt(sock_tcp, SOL_SOCKET, SO_RCVBUF, (void*)&recv_buf, &len);
if (state)
error_handling("getsockopt() error!");

printf("Default input buffer size: %d (B)\n", recv_buf);
printf("Default output buffer size: %d (B)\n", send_buf);

return 0;
}

void error_handling(const char* msg) {
fputs(msg, stderr);
fputc('\n', stderr);
exit(1);
}

运行结果

1
2
Default input buffer  size: 1048576 (B)
Default output buffer size: 524288 (B)

设置输入/输出缓冲大小

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
// set_buf.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>

void error_handling(const char* msg);

int main(int argc, char** argv) {
int send_buf = 1024*3, recv_buf = 1024 * 3, state;
int sock_tcp;
socklen_t len;

sock_tcp = socket(PF_INET, SOCK_STREAM, 0);
state = setsockopt(sock_tcp, SOL_SOCKET, SO_SNDBUF, (void*)&send_buf, sizeof(send_buf));
if (state)
error_handling("setsockopt(snd) error!");

state = setsockopt(sock_tcp, SOL_SOCKET, SO_RCVBUF, (void*)&recv_buf, sizeof(recv_buf));
if (state)
error_handling("setsockopt(rcv) error!");

send_buf = 0;
recv_buf = 0;
len = sizeof(send_buf);
state = getsockopt(sock_tcp, SOL_SOCKET, SO_SNDBUF, (void*)&send_buf, &len);
if (state)
error_handling("getsockopt(snd) error!");

len = sizeof(recv_buf);
state = getsockopt(sock_tcp, SOL_SOCKET, SO_RCVBUF, (void*)&recv_buf, &len);
if (state)
error_handling("getsockopt(rcv) error!");

printf("Set input buffer size: %d (B)\n", recv_buf);
printf("Set output buffer size: %d (B)\n", send_buf);

return 0;
}

void error_handling(const char* msg) {
fputs(msg, stderr);
fputc('\n', stderr);
exit(1);
}

运行结果

1
2
Set input buffer  size: 6144 (B)
Set output buffer size: 6144 (B)

Time_Out状态和地址再分配(SO_REUSEADDR)

之前讲过服务端和客户端断开连接的操作(四次握手),最后是客户端先向服务端发送一条FIN消息,经过四次握手之后断开连接,之后再重启服务器都很正常

但是如果强制关闭服务器端,即让服务器端先向客户端发送FIN消息,然后很快用统一端口号重启服务器,那么会发生bind() error!,无法再次运行,这种情况下要等几分钟才能重新运行服务器端

上面的区别仅在于谁先发送FIN消息,但是结果却很不同,究其原因先要了解Time-Out状态
(假设下图A是Server,B是Client)

TimeWait

先断开连接(先发送FIN消息)的主机才会经过Time-Wait状态,这个状态就是为了确保最后的确认消息不会丢失而存在的,如果没有Time-Wait状态,最后A发送给B的确认消息丢失了,A不会重传ACK消息给B,而B会一直重传FIN消息给A

而在Time-Wait的过程中,实际上socket还存在,端口号也正在使用,这也就是为什么服务端先断开连接会出问题的原因了,而之所以对客户端没有影响,是因为客户端使用的端口号都是动态分配的,服务端是固定的

Time-Wait状态开始于先发送FIN消息的主机收到FIN消息时,而且每一次收到FIN消息都会重新进入Time-Wait状态(还是用AB主机举例),如果网络状态不理想,那么B一直重传FIN消息给A,A就会一直处于Time-Wait状态,这肯定不行,所以有时候可以使用地址再分配解决这个问题

更改套接字可选项SO_REUSEADDR,可以将Time-Wait状态下的套接字端口号重新分配给新的套接字,其默认值为0(false)

修改一下前面写过的echo服务端,在创建好socket之后加上下面这么几行,然后在服务端CTRL+C关闭服务端之后再重启试试,是能够做到立即重启服务端的

1
2
3
4
5
6
7
8
int option = 1; // true
socklen_t optlen;
int state;
...
serv_sock = socket(...);
...
optlen = sizeof(option);
state = setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR,(void*)&option,optlen);

Nagle算法(TCP_NODELAY控制)

Nagle算法TCP连接专有,默认启用,核心目的是减少小数据包数量、节省带宽、避免网络拥塞

规则:未收到前一数据的 ACK 确认时,会将新数据暂存输出缓冲,攒包再发。意思就是不着急发小数据,先存缓冲,等 ACK 或凑满包再发送,减少网络小碎片包

现代开发用法

  • 大文件传输:数据塞满缓冲才发(因为传入输出缓冲比网络传输快得多),开 / 关 Nagle 数据包数量无明显差异,所以应该禁用Nagle,这样等待ACK的时间就节省下来了;
  • 小数据场景:省带宽可开,要低延迟必须禁用

4202年了,现代人的网络带宽充足,实际开发几乎默认关闭

在开发中需要TCP 可靠性 + UDP 低延迟(游戏、实时聊天、远程控制),直接关闭 Nagle即可,但这是针对对低延迟没那么高的要求的情况,要知道tcp自己建立连接,三次握手啥的一套机制还是有的,要是对低延迟要求很高,老老实实UDP

举个栗子,需要可靠不丢包、又要即时响应的策略游戏,TCP 关 Nagle 完美适配;UDP 只适合帧率 / 实时性压倒一切的快节奏游戏

禁用Nagle使用TCP协议选项TCP_NODELAY,将它改为 1(true) 即可

1
2
int opt_val = 1;
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void*)&optval, sizeof(optval));

查看

1
2
opt_len = sizeof(opt_val);
getsocket(sock, IPPROTO_TCP, TCP_NODELAY, (void*)&optval, &opt_len);

Windows 下 getsockopt / setsockopt 详解

函数使用与Linux下并无差异,Windows 下函数声明(标准格式)

1
2
3
4
5
6
7
8
9
10
#include <winsock2.h>
#include <ws2tcpip.h>

// 设置套接字选项
int setsockopt(SOCKET s, int level, int optname, const char* optval, int optlen);
// 成功返回 0,失败返回 SOCKET_ERROR(-1)

// 获取套接字选项
int getsockopt(SOCKET s, int level, int optname, char* optval, int* optlen);
// 成功返回 0,失败返回 SOCKET_ERROR(-1)
参数 含义
SOCKET s 要操作的套接字句柄(服务端/客户端socket都可)
int level 选项所属层级(核心两个):1. SOL_SOCKET:套接字通用层(IP/端口相关);2. IPPROTO_TCP:TCP协议层(TCP算法相关)
int optname 具体选项名(本次用两个:SO_REUSEADDR / TCP_NODELAY
const char* optval 指向选项值的指针(传入要设置的参数)
int optlen 选项值的字节长度setsockopt 为入参)
char* optval 接收选项值的缓冲区(getsockopt 为出参)
int* optlen 入参:缓冲区大小;出参:实际返回长度(仅getsockopt
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
// set sendbuf/recvbuf
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <winsock2.h>
void ErrorHandling(char *message);
void ShowSocketBufSize(SOCKET sock);

int main(int argc, char *argv[])
{
WSADATA wsaData;
SOCKET hSock;
int sndBuf, rcvBuf, state;

if(WSAStartup(MAKEWORD(2,2),&wsaData) != 0)
ErrorHandling("WSAStartup() error!");
hSock=socket(PF_INET,SOCK_STREAM, 0);

ShowSocketBufSize(hSock);
sndBuf = 1024 * 3,rcvBuf = 1024 * 3;
state=setsockopt(hSock, SOL_SOCKET, SO_SNDBUF, (char*)&sndBuf, sizeof(sndBuf));
if(state==SOCKET_ERROR)
ErrorHandling("setsockopt() error!");

state=setsockopt(hSock,SOL_SOCKET, SO_RCVBUF, (char*)&rcvBuf, sizeof(rcvBuf));
if(state==SOCKET_ERROR)
ErrorHandling("setsockopt() error!");
ShowSocketBufSize(hSock);

closesocket(hSock);
WSACleanup();
return 0;
}

void ShowSocketBufSize(SOCKET sock) {
int sndBuf, rcvBuf, state, len;

len = sizeof(sndBuf);
state = getsockopt(sock, SOL_SOCKET, SO_SNDBUF,(char*)&sndBuf,&len);
if(state == SOCKET_ERROR)
ErrorHandling("getsockopt() error");

len=sizeof(rcvBuf);
state=getsockopt(sock,SOL_SOCKET, SO_RCVBUF,(char*)&rcvBuf, &len);
if(state==SOCKET_ERROR)
ErrorHandling("getsockopt() error");

printf("Input buffer size:%d \n", rcvBuf);
printf("Output buffer size: %d \n", sndBuf);
}

void ErrorHandling(char *message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

Chapter10 多进程服务器端

多进程服务器端,核心思路就是主进程专门负责监听客户端连接,每有一个新客户端连入,就创建一个子进程专门为这个客户端提供服务,父进程继续监听新连接。

依靠操作系统对进程的独立调度,实现同时处理多个客户端的并发效果;重点要搞懂进程创建、僵尸进程的产生与避免、信号机制处理子进程退出,最终用这套模型写出能同时响应多个客户端的TCP服务器,还可以拆分读写逻辑让通信更高效。

进程的概念和应用

进程是操作系统进行资源分配和调度的最小单位,通俗来说,就是正在运行的程序实例——一个程序(比如.exe文件、可执行脚本)被加载到内存中执行,就变成了一个进程,每个进程都有自己独立的内存空间、PID(进程ID),互不干扰,即使是同一个程序,多次运行也会产生多个独立进程。

在Linux系统中,我们可以用ps au命令查看当前系统中所有正在运行的进程,这个命令能直观展示进程的关键信息:比如PID(进程唯一标识)、USER(进程所属用户)、%CPU(进程占用CPU比例)、%MEM(占用内存比例)、COMMAND(启动进程的命令),日常开发中,我们常用这个命令查看进程是否正常运行、定位占用资源过高的进程,也是排查多进程服务器问题的基础工具。

重点掌握用fork()函数创建进程——这是多进程服务器的核心操作。fork()函数由父进程调用,调用后会创建一个与父进程几乎完全相同的子进程,子进程会复制父进程的内存空间、代码段、数据段等资源,唯一的区别是fork()的返回值:父进程中返回子进程的PID,子进程中返回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
28
// fork.c
#include <stdio.h>
#include <unistd.h>

int gval = 10;
int main(int argc, char* argv[]) {
pid_t pid;
int lval = 20;

gval++, lval += 5;

pid = fork();
if (pid == 0) {
// child process
gval += 2, lval += 2;
} else {
// parent process
gval -= 2, lval -= 2;
}

if (pid == 0) {
printf("child proc: [%d, %d] \n", gval, lval);
} else {
printf("parent proc: [%d, %d] \n", gval, lval);
}

return 0;
}

示例结果:

1
2
3
# ./fork
parent proc: [9, 23]
child proc: [13, 27]

父进程和子进程内存完全独立。

进程和僵尸进程

子进程先于父进程终止时,如果父进程没有主动回收子进程的退出状态信息,子进程就会变成僵尸进程。僵尸进程不会释放占用的进程ID等系统资源,大量产生会耗尽系统资源,导致无法创建新进程。

子进程终止后会释放内存、文件描述符等大部分资源,但会保留包含自身PID、退出状态等信息的进程结构体,等待父进程通过系统函数读取后才会彻底销毁;若父进程一直未执行回收操作,这个残留的子进程就会以僵尸状态存在。

这种情况在三次元称之为,自己的孩子自己要负责,操作系统是个宽容不管事的大平台,会持久保存子进程状态但不会主动释放子进程。

我们可以通过一段简单代码制造僵尸进程:父进程调用fork()创建子进程后,进入长时间休眠状态;子进程创建完成后直接退出,此时父进程未回收子进程资源,子进程就会变为僵尸进程,通过ps au命令可以查看到该进程状态为Z(僵尸状态)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// zombie.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main()
{
pid_t pid = fork();

if (pid == 0)
{
printf("子进程(%d) 已终止,等待父进程回收...\n", getpid());
return 0;
}

printf("父进程(%d) 休眠中,未回收子进程资源\n", getpid());
printf("父进程中打印子进程pid:%d \n", pid);
sleep(10);

return 0;
}

执行结果:

1
2
3
4
# ./zombie 
父进程(5011) 休眠中,未回收子进程资源
父进程中打印子进程pid:5012
子进程(5012) 已终止,等待父进程回收...

使用 ps au | grep zombie 查看僵尸进程:

僵尸进程

10秒后父进程睡眠结束,回收子进程资源,程序自动结束。

为了销毁僵尸进程、回收系统资源,父进程必须调用wait()waitpid()函数。

下面进行详细解释和示例演示。

wait() 函数的作用详解:阻塞父进程,等待任意一个子进程终止,同时回收子进程资源(销毁僵尸进程),并获取子进程的退出状态值

函数声明:

1
2
#include <sys/wait.h>
pid_t wait(int *status);
  • 参数 status:指向整型变量的指针,用于存储子进程的退出状态信息
  • 返回值:成功返回终止的子进程PID,失败返回 -1

通过 wait 拿到的状态值 status 不能直接使用,需要用宏解析:

  1. WIFEXITED(status)
    • 作用:判断子进程是否正常终止return 退出 / exit 退出)
    • 返回值:正常终止 → 非0;异常终止 → 0
  2. WEXITSTATUS(status)
    • 作用:获取子进程正常退出时的返回值(子进程 return 的数值)
    • 前提:必须先通过 WIFEXITED 确认子进程正常终止

wait.c 示例代码(回收僵尸进程 + 获取子进程返回值)

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
// wait.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(int argc, char *argv[])
{
int status;
pid_t pid = fork();

// 第一个子进程:return 3
if(pid == 0)
{
return 3;
}
else
{
printf("CHILD PID: %d \n", pid);
pid = fork();

// 第二个子进程:exit(7)
if(pid == 0)
{
exit(7);
}
else
{
printf("CHILD PID: %d \n", pid);

// 第一次等待:回收任意子进程,获取状态
wait(&status);
if(WIFEXITED(status))
printf("Child send one: %d \n",WEXITSTATUS(status));

// 第二次等待:回收另一个子进程,获取状态
wait(&status);
if(WIFEXITED(status))
printf("Child send two: %d \n", WEXITSTATUS(status));

sleep(10); // 休眠10秒,父进程保持运行
}
}
return 0;
}

示例结果:

1
2
3
4
5
# ./wait
CHILD PID: 9126
CHILD PID: 9127
Child send one: 3
Child send two: 7

当我们用ps au | grep wait查看进程信息时,并没有僵尸进程产生,子进程资源被完全回收,父进程成功获取子进程返回值。

下面讲 waitpid

函数声明:

1
2
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);

核心参数 & 返回值:

  1. pid_t pid
    • -1:等待任意子进程退出(和 wait 作用一致)
    • 正数:等待指定PID的子进程退出
  2. *int status:存储子进程的退出状态(和 wait 用法一致)
  3. int options:核心选项
    • WNOHANG非阻塞模式(重点),没有子进程退出时,函数立即返回 0,不阻塞父进程
  4. 返回值
    • 成功:返回退出的子进程PID
    • 无子进程退出(仅WNOHANG):返回 0
    • 失败:返回 -1

关键宏(沿用之前的用法):

  • WIFEXITED(status):判断子进程是否正常退出
  • WEXITSTATUS(status):获取子进程的退出返回值
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
// waitpid.c
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main(int argc, char *argv[])
{
int status;
pid_t pid = fork();

// 子进程:休眠15秒后退出,返回值24
if(pid == 0)
{
sleep(15);
return 24;
}
// 父进程:非阻塞轮询回收子进程
else
{
// waitpid返回0时,循环继续;返回子进程PID时,循环结束
while(!waitpid(-1, &status, WNOHANG))
{
sleep(3);
puts("sleep 3sec.");
}

// 解析子进程退出状态
if(WIFEXITED(status))
printf("Child send %d \n", WEXITSTATUS(status));
}

return 0;
}

示例结果:

1
2
3
4
5
6
7
# ./waitpid 
sleep 3sec.
sleep 3sec.
sleep 3sec.
sleep 3sec.
sleep 3sec.
Child send 24

同样,并没有僵尸进程产生。

信号处理

本节核心解决子进程终止时机未知、主动wait/waitpid会阻塞/浪费资源的问题:我们无法预知子进程何时终止,不能让父进程无休止等待,也不能循环轮询,而信号是操作系统的主动通知机制——事件发生时系统自动发信号,进程绑定处理函数响应,完美解决该问题。本节先提出核心问题,依次讲解signal(过时)、alarm函数,再讲解标准的sigaction函数与结构体,最终用信号实现自动销毁僵尸进程,是多进程服务器的核心技术。

我们已经知道了进程创建及销毁方法,但还有一个问题没解决。“子进程究竟何时终止?调用waitpid函数后要无休止地等待吗?” 答案就是信号处理。

signal 函数(过时,仅了解)

1
2
3
#include <signal.h>
// 函数声明
void (*signal(int sig, void (*handler)(int)))(int);

参数说明

  • int sig:要注册的信号编号(如 SIGALRM:已到通过调用alarm函数注册的时间,SIGCHLD:子进程终止,SIGINT:输入Ctrl + C)
  • void (*handler)(int):信号处理函数指针,收到信号时自动执行该函数

返回值

  • 成功:返回之前的信号处理函数指针
  • 失败:返回 SIG_ERR

缺陷:不同UNIX/Linux系统实现不一致,兼容性差、不稳定,已被弃用。

alarm 函数(定时发送信号)

1
2
3
#include <unistd.h>
// 函数声明
unsigned int alarm(unsigned int seconds);

参数说明

  • unsigned int seconds:秒数,到达时间后系统向进程发送 SIGALRM 信号

返回值

  • 之前设置的闹钟剩余秒数,无闹钟则返回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
28
29
30
31
32
33
34
35
// signal.c
#include <stdio.h>
#include <unistd.h>
#include <signal.h>

// SIGALRM: 超时
// SIGINT: 用户中断
// SIGCHLD: 子进程终止

void timeout(int sig) {
if (sig = SIGALRM)
puts("Time out!");
alarm(2); // 预约2秒后产生SIGALRM信号
}

void keycontrol(int sig) {
if (sig == SIGINT)
puts("CTRL+C pressed!");
}

int main(int argc, char* argv[]) {
int i;
// 注册信号
signal(SIGALRM, timeout);
signal(SIGINT, keycontrol);
// 预约2秒后产生SIGALRM信号,开始信号
alarm(2);

for (i = 0; i < 3; ++i) {
puts("wait...");
sleep(100);
}

return 0;
}

示例结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 不干预执行,会输出:
wait...
Time out!
wait...
Time out!
wait...
Time out!

# 干预执行(用 ctrl + c 终止):
wait...
Time out!
wait...
^CCTRL+C pressed!
wait...
Time out!

struct sigaction 结构体(信号配置核心)

1
2
3
4
5
6
7
#include <signal.h>
// 结构体声明
struct sigaction {
void (*sa_handler)(int); // 信号处理函数
sigset_t sa_mask; // 信号屏蔽集
int sa_flags; // 附加标志
};

成员说明

  • sa_handler:指定信号处理函数,同signal的handler参数
  • sa_mask:处理信号时需要屏蔽的信号集合,用sigemptyset初始化为空集
  • sa_flags:附加特性,默认填0即可满足常规使用

sigaction 函数(现代标准信号注册函数)

1
2
3
#include <signal.h>
// 函数声明
int sigaction(int sig, const struct sigaction *act, struct sigaction *oldact);

参数说明

  • int sig:与signal函数相同,传递信号信息
  • const struct sigaction *act:对应于第一个参数的信号处理函数信息(目前只需要了解这里保存信号处理函数的函数指针即可)
  • struct sigaction *oldact:保存旧的信号配置(之前注册的),不需要则传0

返回值

  • 成功返回0,失败返回-1

优势:所有Linux系统行为完全一致,稳定可靠,替代signal函数。

sigaction + SIGALRM 示例(修正可运行)

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
#include <stdio.h>
#include <unistd.h>
#include <signal.h>

// 信号处理函数
void timeout(int sig)
{
if(sig == SIGALRM)
puts("Time out!");
alarm(2);
}

int main(int argc,char *argv[])
{
int i;
struct sigaction act;
act.sa_handler = timeout;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGALRM, &act, 0);

alarm(2);

for(i=0; i<3; i++)
{
puts("wait...");
sleep(100);
}
return 0;
}

示例结果:

1
2
3
4
5
6
7
# ./sigaction 
wait...
Time out!
wait...
Time out!
wait...
Time out!

信号处理消灭僵尸进程示例

子进程终止时,系统自动向父进程发送SIGCHLD信号,注册该信号的处理函数,在函数中调用waitpid自动回收子进程,彻底杜绝僵尸进程。

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
// remove_zombie.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>

// SIGCHLD信号处理函数:自动回收子进程
void read_childproc(int sig){
int status;
pid_t id = waitpid(-1, &status, WNOHANG);
if(WIFEXITED(status))
{
printf("Removed proc id: %d\n", id);
printf("Child send: %d\n", WEXITSTATUS(status));
}
}

int main(int argc, char *argv[])
{
pid_t pid;
struct sigaction act;
act.sa_handler = read_childproc;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGCHLD, &act, 0);

// 创建第一个子进程
pid = fork();
if(pid == 0)
{
puts("Hi! I'm child process");
sleep(10);
return 12;
}
else
{
printf("Child proc id: %d\n", pid);
// 创建第二个子进程
pid = fork();
if(pid == 0)
{
puts("Hi! I'm child process");
sleep(10);
exit(24);
}
else
{
int i;
printf("Child proc id: %d\n", pid);
for(i=0; i<5; i++)
{
puts("wait...");
sleep(5);
}
}
}
return 0;
}

示例结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# ./remove_zombie     
Child proc id: 30503
Child proc id: 30504
Hi! I'm child process
wait...
Hi! I'm child process
wait...
Removed proc id: 30503
Child send: 12
Removed proc id: 30504
Child send: 24
wait...
wait...
wait...

基于多任务的并发服务器

我们之前学的单进程服务器同一时间只能服务一个客户端,而多任务并发服务器就是解决这个问题:由父进程专门负责监听客户端连接,每有一个新客户端连接,就调用 fork() 创建一个子进程,让子进程单独和这个客户端通信,父进程则回到监听状态,等待下一个客户端。这样就能实现多人同时连接、同时通信,配合之前学的 SIGCHLD 信号自动回收子进程,彻底避免僵尸进程,这是Linux下经典的并发服务器模型。

多任务并发回声服务器示例(客户端我们使用5.1 echo客户端的完美实现):

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
// echo_mpserv.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30

void error_handling(char *message);
void read_childproc(int sig);

int main(int argc, char *argv[])
{
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
pid_t pid;
struct sigaction act;
socklen_t adr_sz;
int str_len;
char buf[BUF_SIZE];

if(argc!=2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}

// 注册SIGCHLD信号处理函数,自动回收子进程
act.sa_handler = read_childproc;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGCHLD, &act, 0);

// 创建监听套接字
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));

// 绑定+监听
if(bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
if(listen(serv_sock, 5) == -1)
error_handling("listen() error");

// 无限循环监听客户端连接
while(1)
{
adr_sz = sizeof(clnt_adr);
// 接收客户端连接
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
if(clnt_sock == -1)
continue;
else
puts("new client connected...");

// 创建子进程
pid = fork();
if(pid == -1)
{
close(clnt_sock);
continue;
}

// 子进程:负责和客户端通信
if(pid == 0)
{
close(serv_sock); // 子进程关闭监听套接字文件描述符
// 回声服务:读取客户端数据并回传
while((str_len = read(clnt_sock, buf, BUF_SIZE)) != 0)
write(clnt_sock, buf, str_len);

close(clnt_sock);
puts("client disconnected...");
return 0;
}
// 父进程:关闭客户端套接字文件描述符,继续监听
else
{
close(clnt_sock);
}
}
close(serv_sock);
return 0;
}

// 子进程退出信号处理函数:回收僵尸进程
void read_childproc(int sig)
{
pid_t pid;
int status;
pid = waitpid(-1, &status, WNOHANG);
printf("Removed proc id: %d \n", pid);
}

// 错误处理函数
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(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
# 终端中运行服务器 ./echo_mpserv 9527
# 多开几个终端模拟多客户端连接 ./client_echo2 127.0.0.1 9527
Connected........
Input message(Q to quit): hello
Message from server: hello
Input message(Q to quit): 人类的本质是
Message from server: 人类的本质是
Input message(Q to quit): 复读鸡你太美babyoh~
Message from server: 复读鸡你太美babyoh~
Input message(Q to quit): Q

# 客户端建立连接和断开连接,服务器端显示
new client connected...
new client connected...
new client connected...
new client connected...
client disconnected...
Removed proc id: 11184
client disconnected...
Removed proc id: 11405
client disconnected...
Removed proc id: 11132
client disconnected...
Removed proc id: 11074
^C

文件描述符 和 套接字 的关系(注意第3、4点):

  1. 套接字(Socket)操作系统内核创建的网络通信资源,属于内核对象,存在于系统内核中,进程不能直接操作套接字本身。
  2. 文件描述符进程分配的一个整数编号,本质是进程对内核套接字的引用/句柄,进程通过文件描述符来间接使用内核中的套接字。
  3. 调用 fork() 创建子进程时,绝对不会复制内核套接字,内核中始终只有一个套接字对象;子进程只会复制父进程的文件描述符表,同时内核会给这个套接字的引用计数 +1
  4. 代码中父子进程都要关闭多余文件描述符的原因:子进程不需要监听客户端,所以关闭 serv_sock(监听套接字的文件描述符);父进程不需要和客户端通信,所以关闭 clnt_sock(客户端套接字的文件描述符),这是为了保证引用计数正确。

最后是最核心的规则:
内核中的套接字,只有当引用它的所有文件描述符都被关闭(引用计数变为0)时,操作系统才会真正销毁这个套接字,释放系统资源
只要还有一个进程持有该套接字的文件描述符没有关闭,套接字就会一直存在,不会被销毁。

分割TCP的I/O程序

分割TCP的I/O程序,核心就是将数据的读取(Read)和写入(Write)拆分为两个独立的执行流程,本节课我们通过父子进程实现分离:父进程只负责接收服务端数据,子进程只负责向服务端发送数据。书中特意说明,回声客户端本身完全不需要I/O分割,这样做反而会增加编程复杂度,本节的核心目的只是演示I/O分离的思想,以及解决多进程场景下的关键问题——如何正确发送EOF断开连接,这也是shutdown函数的核心用法。

I/O分割的通用优势(复杂业务场景适用):读写操作互不干扰,避免单线程中read阻塞导致无法发送数据,或write阻塞导致无法接收数据,让数据收发更流畅,是高性能网络程序的常用设计。

TCP I/O分割客户端代码:

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
// echo_mpclient.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30

void error_handling(char *message);
void read_routine(int sock, char *buf);
void write_routine(int sock, char *buf);

int main(int argc, char *argv[])
{
int sock;
pid_t pid;
char buf[BUF_SIZE];
struct sockaddr_in serv_adr;

if(argc!=3) {
printf("Usage: %s <IP> <port>\n", argv[0]);
exit(1);
}

sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
serv_adr.sin_port = htons(atoi(argv[2]));

if(connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("connect() error!");

// fork创建子进程,分割I/O
pid = fork();
if(pid == 0)
write_routine(sock, buf); // 子进程:只负责写入(发送数据)
else
read_routine(sock, buf); // 父进程:只负责读取(接收数据)

close(sock);
return 0;
}

// 读流程:接收服务端回传的数据
void read_routine(int sock, char *buf)
{
while(1)
{
int str_len = read(sock, buf, BUF_SIZE);
if(str_len == 0)
return;
buf[str_len] = 0;
printf("Message from server: %s", buf);
}
}

// 写流程:向服务端发送数据,输入q/Q退出
void write_routine(int sock, char *buf)
{
while(1)
{
fgets(buf, BUF_SIZE, stdin);
// 输入q/Q,关闭写流并退出
if(!strcmp(buf, "q\n") || !strcmp(buf, "Q\n"))
{
// 核心:半关闭写流,发送EOF
shutdown(sock, SHUT_WR);
return;
}
write(sock, buf, strlen(buf));
}
}

// 错误处理
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

示例结果:

1
2
3
4
5
6
# ./echo_mpclient 127.0.0.1 9527
hello
Message from server: hello
hi
Message from server: hi
q

上面的代码中我们用到了 shutdown,关闭了写流,对于这种半关闭的场景,其实用在复杂服务器中比较常见,比如我们上传文件,写流是打开的,发完文件后写流立即关闭,但是读流还在(父进程),我们依然可以收到服务器给我们返回的数据,他可能会统计一下我们发的文件信息给我们。这里的回声客户端输入q之后,立刻就退出了,逻辑处理太过简单验证不到这个场景。,而且这里客户端输入q,子进程退出父进程立马就退,也没必要处理僵尸子进程,基本不会产生。

下面讲讲 shutdown函数的用法、作用,以及为什么必须用它,而不能用close

先给出shutdown函数的标准声明和参数解析:

1
2
#include <sys/socket.h>
int shutdown(int sock, int howto);
  • 参数sock:要关闭的套接字文件描述符
  • 参数howto:关闭方式,有三个可选值
    1. SHUT_RD:关闭读流,无法再接收数据
    2. SHUT_WR:关闭写流,无法再发送数据(本节使用)
    3. SHUT_RDWR:同时关闭读/写流,完全断开连接
  • 返回值:成功返回0,失败返回-1

shutdown的核心特性:它是「流关闭」,不是「文件描述符关闭」,无论套接字的引用计数是多少,都会立即关闭指定的数据流,并向对方 发送EOF

现在结合代码和fork的文件描述符复制机制,讲清楚为什么不能用close

  1. 调用fork创建子进程后,父进程和子进程会各持有一份套接字的文件描述符,内核中这个套接字的引用计数会变成2
  2. 如果我们在子进程退出时调用close(sock):只会关闭子进程的文件描述符,引用计数减为1,父进程的文件描述符依然存在,套接字不会被销毁,不会向服务端发送EOF,服务端的read会一直阻塞等待数据,无法断开连接;
  3. 而调用shutdown(sock, SHUT_WR):直接关闭套接字的写数据流,无视引用计数,立刻向服务端发送EOF,服务端收到EOF后,read函数会返回0,就能正常识别客户端断开连接。

简单总结:

  • close:关闭文件描述符,依赖引用计数,计数为0才会销毁套接字、发EOF;
  • shutdown:关闭数据流,立即生效,强制发送EOF,是TCP半关闭的标准用法。

本节课的代码只是演示I/O分割和shutdown的用法,回声客户端本身不需要这么复杂,但掌握这种读写分离+半关闭的思想,是编写高性能、稳定网络程序的关键基础。

Chapter11 进程间通信

每个进程在操作系统中都是资源隔离的独立个体,拥有各自专属内存空间,默认完全无法直接互相访问数据、传递信息。进程间通信(IPC),就是操作系统提供的、专门用于多个进程之间交换数据、完成协作交互的技术方案。

本章主要讲解Linux下基础的进程间通信原理与实现方式,单纯看和基础多进程回声服务端开发联系不大,但它是复杂服务端架构的重要铺垫:后续想要实现多进程任务分发、子进程数据汇总、进程协同工作等进阶需求,都离不开IPC;掌握进程间通信,才能开发结构更多样、功能更复杂的各类服务端程序。

进程间通信的基本概念

进程拥有完全独立的内存空间,即便通过fork创建的父子进程,内存也是完全隔离的,无法直接交换数据。想要实现进程间通信,核心就是借助操作系统提供的、多个进程都能访问的公共内存空间,最基础的实现方式就是管道(Pipe),通过pipe函数创建内核级管道,完成父子进程的数据传输。

pipe 函数详解:

1
2
#include <unistd.h>
int pipe(int fds[2]);

参数说明

  • int fds[2]:传入整型数组,函数执行后会返回两个文件描述符
    • fds[0]:管道读端,仅用于从管道中读取数据
    • fds[1]:管道写端,仅用于向管道中写入数据

返回值

  • 成功:返回 0
  • 失败:返回 -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
// pipe1.c
#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 30

int main(int argc, char *argv[])
{
int fds[2];
char str[] = "Who are you?";
char buf[BUF_SIZE];
pid_t pid;

pipe(fds); // 创建管道
pid = fork(); // 创建子进程

if(pid == 0)
{
// 子进程:向管道写端写入数据
write(fds[1], str, sizeof(str));
}
else
{
// 父进程:从管道读端读取数据
read(fds[0], buf, BUF_SIZE);
puts(buf);
}
return 0;
}

示例结果:

1
2
# ./pipe1
Who are you?

管道默认是单向半双工无法用单个管道实现稳定的双向通信
原因:单个管道只有一个缓冲区,父子进程同时读写会造成数据混乱、读取错误,数据流无法分离。
想要实现稳定的双向通信,必须创建两个独立管道,分别负责「父发子收」和「子发父收」。

单管道双向通信(演示缺陷)

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
// pipe2.c
#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 30

int main(int argc, char *argv[])
{
int fds[2];
char str1[] = "Who are you?";
char str2[] = "Thank you for your message";
char buf[BUF_SIZE];
pid_t pid;

pipe(fds);
pid = fork();

if(pid == 0)
{
// 子进程:先写后读
write(fds[1], str1, sizeof(str1));
sleep(2);
read(fds[0], buf, BUF_SIZE);
printf("Child proc output: %s\n", buf);
}
else
{
// 父进程:先读后写
read(fds[0], buf, BUF_SIZE);
printf("Parent proc output: %s\n", buf);
write(fds[1], str2, sizeof(str2));
sleep(3);
}
return 0;
}

上面的代码必须依靠sleep强制等待避免数据冲突,如果去除会造成子进程自己写自己读,父进程读不到数据无限等待,实际开发中必须用双管道分离数据流。

双管道双向通信:双管道分别负责「子进程发→父进程收」和「父进程发→子进程收」,数据流完全分离,无需 sleep 等待、不会数据冲突

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
#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 30

int main(int argc,char *argv[])
{
int fds1[2], fds2[2];
char str1[]="Who are you?";
char str2[]="Thank you for your message";
char buf[BUF_SIZE];
pid_t pid;

// 创建两个独立管道
pipe(fds1);
pipe(fds2);
pid = fork();

if(pid==0)
{
// 子进程:管道1 写数据 → 发给父进程
write(fds1[1], str1, sizeof(str1));
// 子进程:管道2 读数据 ← 接收父进程回复
read(fds2[0], buf, BUF_SIZE);
printf("Child proc output: %s \n",buf);
}
else
{
// 父进程:管道1 读数据 ← 接收子进程消息
read(fds1[0],buf, BUF_SIZE);
printf("Parent proc output: %s \n",buf);
// 父进程:管道2 写数据 → 回复子进程
write(fds2[1],str2,sizeof(str2));
sleep(3);
}
return 0;
}

示例结果:

1
2
Parent proc output: Who are you? 
Child proc output: Thank you for your message

pipe底层原理(自己添加内容)

  1. 进程用户空间完全独立(32 位:0~3G;64 位:128T),无法直接互通数据;
  2. 所有进程共享内核空间(32 位:3~4G;64 位:128T);
  3. pipe 的本质:操作系统在内核共享空间创建一块缓冲区,作为进程间数据交换的中转站;
  4. 数据通信流程:用户内存 → 拷贝到内核缓冲区 → 拷贝到另一个进程用户内存。

说白了就是把数据从一个独立空间复制到共享空间(内核缓冲区),明明读写都是操作同一个内核缓冲区,为啥非要搞两个文件描述符(读 / 写)?用一个文件描述符既能读又能写,难道不行吗?

这是多方设计考量,并非舍近求远。

首先,pipe 的设计定位是单向数据流,分读写端避免双向混读混写,从底层防止数据错乱。

其次,分离读写端是管道实现 EOF 结束标记的核心前提(写端close才是EOF)。只用单个 fd,内核无法区分行为,不能判断通信是否彻底结束。

最后,管道是动态流动的临时缓冲区;普通文件是静态持久存储(普通文件用一个fd就能读写),二者模型完全不同,不能套用一套读写逻辑。

运用进程间通信

下面是基于基于多任务的并发服务器修改的示例,客户端使用之前任一个都可以,比如分割tcp的io程序的客户端,服务端利用管道把读取到客户端的信息写到文件中,其中读取信息和写文件在两个子进程中,父进程依然只是监听客户端连接。

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
// echo_storeserv.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30

void error_handling(char *message);
void read_childproc(int sig);

int main(int argc, char *argv[])
{
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
int fds[2];
pid_t pid;
struct sigaction act;
socklen_t adr_sz;
int str_len;
char buf[BUF_SIZE];

if(argc!=2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}

// 注册SIGCHLD信号处理函数,自动回收子进程
act.sa_handler = read_childproc;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGCHLD, &act, 0);

// 创建监听套接字
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));

// 绑定+监听
if(bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
if(listen(serv_sock, 2) == -1)
error_handling("listen() error");

pipe(fds);
pid = fork();

if (pid == 0) {
FILE* fp = fopen("echo_storage.txt", "wt");
char msgbuf[BUF_SIZE];
int i, len;

if (fp == NULL) error_handling("fopen error!");

// 写入任意数目数据(需要实时写入)
// while (1) {
// len = read(fds[0], msgbuf, BUF_SIZE);
// if (len <= 0) break;
// fwrite((void*)msgbuf, 1, len, fp); // 只写内存,不写磁盘
// fflush(fp); // 立即写入磁盘,不缓存
// }

// 缓存三条数据
for(i = 0; i < 3; ++i) {
len = read(fds[0], msgbuf, BUF_SIZE);
fwrite((void*)msgbuf, 1, len, fp);
}

fclose(fp); // 此处执行一次 fflush(fp)
return 0;
}

// 无限循环监听客户端连接
while(1)
{
adr_sz = sizeof(clnt_adr);
// 接收客户端连接
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
if(clnt_sock == -1)
continue;
else
puts("new client connected...");

// 创建子进程
pid = fork();
if(pid == -1)
{
close(clnt_sock);
continue;
}

// 子进程:负责和客户端通信
if(pid == 0)
{
close(serv_sock); // 子进程关闭监听套接字文件描述符
// 回声服务:读取客户端数据并回传
while((str_len = read(clnt_sock, buf, BUF_SIZE)) != 0) {
write(clnt_sock, buf, str_len);
write(fds[1], buf, str_len);
}

close(clnt_sock);
puts("client disconnected...");
return 0;
}
// 父进程:关闭客户端套接字文件描述符,继续监听
else
{
close(clnt_sock);
}
}
close(serv_sock);
return 0;
}

// 子进程退出信号处理函数:回收僵尸进程
void read_childproc(int sig)
{
pid_t pid;
int status;
pid = waitpid(-1, &status, WNOHANG);
printf("Removed proc id: %d \n", pid);
}

// 错误处理函数
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

Chapter12 I/O复用

前文多进程服务端通过创建子进程处理多客户端并发,进程创建与调度开销大、并发能力有限,资源利用率低。

I/O 复用是解决该问题的核心IO模型:允许单个进程同时监听多个文件描述符(客户端套接字、管道等)的读写、异常事件。

由内核统一轮询监视所有IO,仅当文件描述符触发就绪事件时,程序才进行数据读写处理,无需阻塞等待单个IO、也无需频繁创建子进程。

本章重点讲解 select 复用函数的原理、调用流程与服务端实战实现,同时兼顾 Windows 平台下的接口差异与代码适配,掌握 I/O 复用是实现轻量、高并发服务器的基础。

基于I/O复用的服务器端

多进程服务器的核心缺陷

  • 此前多进程服务端,每接入一个客户端就创建一个子进程完成通信。频繁创建/销毁进程会消耗大量内存与CPU调度资源;并发客户端数量上限低,连接量大时系统负载急剧升高,整体性能与稳定性都会下降。

I/O 复用服务器的优势

  • I/O 复用仅依靠单个进程,即可同时监听、管理多个套接字IO事件。
  • 由内核统一批量监视所有文件描述符,只有客户端发生连接、可读、可写等就绪事件时,服务端才执行对应业务处理。
  • 无需创建大量子进程,资源开销极低,大幅提升并发承载能力,程序架构更轻量化。

适用场景(I/O 复用并非万能)

  • 适合:长连接多、大部分连接处于空闲等待状态的服务,如聊天服务器、网关、消息转发服务。
  • 不适合:客户端请求伴随大量耗时计算、密集运算的业务,这类场景更适合多进程/多线程架构分担压力。
  • 开发中需要根据业务特性选择模型。

通俗类比理解

  • 多进程模型:餐厅每来一位顾客,就单独聘用一名专属服务员,顾客越多,人力成本和管理压力越大。
  • I/O 复用模型:只用一名总管,同时照看全部餐桌,哪一桌有需求(点餐/结账),就立刻针对性处理,以极低资源高效应对大量顾客。

理解select函数并实现服务器端

select 函数:I/O 复用的核心

书中说 “select 函数是整个 I/O 复用的内容也不为过”,这句话精准至极:
select 是 Linux 下最基础、最经典的 I/O 复用函数,它能让单个进程同时监视多个文件描述符(套接字、标准输入等),直到某个描述符触发I/O 事件(有数据可读/可写/异常),再去处理,不用阻塞等待单个IO。

select 函数标准声明:

1
2
3
4
5
6
7
8
#include <sys/select.h>
int select(
int nfds, // 监视的文件描述符范围
fd_set *readfds, // 监视「读事件」的fd集合
fd_set *writefds, // 监视「写事件」的fd集合
fd_set *exceptfds, // 监视「异常事件」的fd集合
struct timeval *timeout // 超时时间
);

参数解释:

参数 含义
nfds 它的值在Linux下为监视的最大文件描述符 + 1(告诉内核需要检查的fd范围,提升效率,内核暴力遍历 [0, nfds-1] )
readfds 关注读事件的fd集合(如:有数据到达、客户端连接)
writefds 关注写事件的fd集合(极少用,传NULL
exceptfds 关注异常事件的fd集合(极少用,传NULL
timeout 等待超时时间:传NULL=永久阻塞;传结构体=超时后返回

返回值(3种情况):

  • -1:函数调用出错
  • 0超时,没有任何fd发生事件
  • 正整数:发生事件的文件描述符数量

select 核心配套工具:

1. fd_set 数组

本质是位图(bit数组),用来存放需要监视的文件描述符,是 I/O 复用的「监视清单」。

  • 我们把要监听的 fd 加入这个集合
  • select 函数会遍历这个集合,检查是否有事件发生

2. 四个操作宏

专门用来操作 fd_set 集合,是固定用法:

1
2
3
4
FD_ZERO(&fdset);      // 初始化集合,全部位清0
FD_SET(fd, &fdset); // 将指定fd加入集合(注册监视)
FD_CLR(fd, &fdset); // 将指定fd从集合删除
FD_ISSET(fd, &fdset); // 判断fd是否发生了事件(返回1=就绪)

3. struct timeval 超时结构体

设置 select 的等待超时时间:

1
2
3
4
struct timeval {
long tv_sec; // 秒
long tv_usec; // 微秒
};

select 标准调用流程

select 调用必须遵循:初始化 → 备份 → 调用 → 校验 → 处理

flowchart TD
    A[1. 初始化 fd_set 监视集合] --> B[2. 注册需要监听的fd(相应位置为1)]
    B --> C[3. 设置超时时间]
    C --> D[4. 备份原始fd_set集合! 关键]
    D --> E[5. 调用 select 函数]
    E --> F{6. 检查返回值}
    F -->|返回-1| G[出错退出]
    F -->|返回0| H[超时,重新循环]
    F -->|返回正数| I[7. 找就绪fd]
    I --> J[8. 处理数据]
    J --> K[回到备份步骤]
    K --> D

这里有必要备份 fd_set,因为调用 select 之后,除了有变化的fd对应的位,所有位都会被清零,如果不备份直接传原数据,我们的值就被改了,第二次调用 select 监听位可能就会错误。

同理,超时时间也需要在循环内设置,select会改变它。

流程图步骤 伪代码 说明
1. 初始化 FD_ZERO(&reads); 清空位图,所有位 = 0
2. 注册监听 fd FD_SET(0, &reads); 把 fd=0 标记为 1,加入监听
3. 设置超时 timeout.tv_sec=5; 等待 5 秒超时
4. 备份集合 temps = reads; 关键!复制一份给 select 用
5. 调用 select select(1, &temps, ...) 内核检查事件,会修改 temps
6. 判断返回 if(result==0) 超时 / 就绪 / 错误
7. 检查就绪 FD_ISSET(0, &temps) 看哪个 fd 触发了
8. 处理 read(0, buf...) 读取控制台输入
循环 回到步骤 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
// select.c
#include <stdio.h>
#include <unistd.h>
#include <sys/time.h>
#include <sys/select.h>

#define BUF_SIZE 30

int main(int argc, char *argv[])
{
fd_set reads, temps; // reads=原始监视集合,temps=select调用用的备份集合
int result, str_len;
char buf[BUF_SIZE];
struct timeval timeout; // 超时结构体

// 1. 初始化监视集合(清空)
FD_ZERO(&reads);
// 2. 注册:监视 标准输入(文件描述符=0)
FD_SET(0, &reads);

while(1)
{
// 关键:每次调用select前,必须备份原始集合(select会修改集合)
temps = reads;

// 3. 每次循环都设置超时:5秒0微秒
timeout.tv_sec = 5;
timeout.tv_usec = 0;

// 4. 调用select:监视读集合,超时5秒
// 调用后会改变fd_set和timeout
result = select(1, &temps, NULL, NULL, &timeout);

if(result == -1) {
puts("select() error!");
break;
}
else if(result == 0) {
puts("Time-out!"); // 5秒无输入,超时
}
else {
// 5. 判断:标准输入是否就绪(有数据输入)
if(FD_ISSET(0, &temps)) {
str_len = read(0, buf, BUF_SIZE);
buf[str_len] = 0;
printf("message from console: %s", buf);
}
}
}
return 0;
}

示例结果:

1
2
3
4
5
6
7
8
9
# ./select                     
Time-out!
Time-out!
hello
message from console: hello
Time-out!
鸡你太美
message from console: 鸡你太美
^C
实现I/O复用服务器端

因为文件描述符的创建是整数递增的,所以这里 fd_max 我们可以直接赋值为最新创建的文件描述符,供 select 函数的第一个参数使用。

后面我们客户端连接服务端,创建了新的文件描述符,我们要更新fd_max,把客户端文件描述符加入监听队列。

客户端我们使用5.1 echo客户端的完美实现

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
// echo_selectserv.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <sys/select.h>

#define BUF_SIZE 100

void error_handling(char *buf);

int main(int argc, char *argv[])
{
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
struct timeval timeout;
fd_set reads, cpy_reads; // 原始集合 + 备份集合(select核心)
socklen_t adr_sz;
int fd_max, str_len, fd_num, i;
char buf[BUF_SIZE];

// 1. 参数检查
if (argc != 2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}

// 2. 创建服务端套接字 + 绑定 + 监听
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));

if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");

// 3. 初始化 select 监视集合
FD_ZERO(&reads);
FD_SET(serv_sock, &reads); // 注册服务端socket(监视连接事件)
fd_max = serv_sock; // 记录当前最大文件描述符(select第一个参数用)

// 4. select 主循环(核心!)
while (1)
{
// ========== select 固定流程:备份原始集合 ==========
cpy_reads = reads;
// 设置超时:5秒0微秒
timeout.tv_sec = 5;
timeout.tv_usec = 0;

// ========== 调用 select 函数 ==========
// 参数1:最大fd+1; 参数2:读集合; 超时时间
fd_num = select(fd_max + 1, &cpy_reads, NULL, NULL, &timeout);
if (fd_num == -1) // select 出错
break;
if (fd_num == 0) // 超时,无事件
continue;

// ========== 遍历所有fd,查找就绪的fd ==========
for (i = 0; i < fd_max + 1; i++)
{
// 判断当前fd是否有事件
if (FD_ISSET(i, &cpy_reads))
{
// 情况1:服务端fd就绪 → 有新客户端连接
if (i == serv_sock)
{
adr_sz = sizeof(clnt_adr);
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);

// 新客户端socket 注册到 select 监视集合
FD_SET(clnt_sock, &reads);
// 更新最大fd
if (fd_max < clnt_sock)
fd_max = clnt_sock;
printf("connected client: %d\n", clnt_sock);
}
// 情况2:客户端fd就绪 → 有数据可读
else
{
str_len = read(i, buf, BUF_SIZE);
// 客户端断开连接
if (str_len == 0)
{
FD_CLR(i, &reads); // 从监视集合中删除
close(i); // 关闭套接字
printf("closed client: %d\n", i);
}
// 回声:将数据回写给客户端
else
{
write(i, buf, str_len);
}
}
}
}
}

close(serv_sock); // 关闭服务端socket
return 0;
}

// 错误处理函数
void error_handling(char *buf)
{
fputs(buf, stderr);
fputc('\n', stderr);
exit(1);
}

示例结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 服务端
# ./echo_selectserv 9527
connected client: 4
closed client: 4

# 客户端
# ./client_echo2 127.0.0.1 9527
Connected........
Input message(Q to quit): hello
Message from server: hello
Input message(Q to quit): how are you
Message from server: how are you
Input message(Q to quit): Q

基于Windows的实现

Windows 与 Linux 的 select 函数用法高度统一,但底层实现有关键差异

select函数声明

1
2
3
4
5
6
7
8
9
#include <winsock2.h>

int select(
int nfds, // 【无用参数】仅兼容Linux,固定填0
fd_set *readfds, // 读事件监视集合
fd_set *writefds, // 写事件监视集合
fd_set *exceptfds, // 异常事件监视集合
const struct timeval *timeout // 超时时间
);

参数说明

参数 含义
nfds Windows 下完全废弃,为了和Linux语法统一,固定传 0
readfds/writefds/exceptfds 监视集合,用法和Linux一致
timeout 超时结构体,和Linux完全相同

返回值

  • SOCKET_ERROR (-1):函数调用失败
  • 0:超时,无就绪套接字
  • 正整数:就绪的套接字数量(含义和Linux完全一致)

Windows 版 fd_set(和linux的核心差异点)

Windows 中 fd_set 不是位图,而是记录句柄数量 + 数组存储句柄(Windows 套接字句柄无规律、不从0开始)

1
2
3
4
typedef struct fd_set {
u_int fd_count; // 存储的套接字句柄 数量
SOCKET fd_array[FD_SETSIZE]; // 存储套接字句柄的数组
} fd_set;

timeval 结构体:

和 Linux 完全相同,无任何差异。

1
2
3
4
struct timeval {
long tv_sec; // 秒
long tv_usec; // 微秒
};

fd_set 操作宏

宏名称、用法和 Linux 完全一致(底层实现不同,上层无感):

1
2
3
4
FD_ZERO(&fdset);    // 清空集合
FD_SET(sock, &fdset); // 添加套接字到监视集合
FD_CLR(sock, &fdset); // 从集合删除套接字
FD_ISSET(sock, &fdset); // 判断套接字是否就绪

Windows 下实现上述 select 回声服务器(客户端使用windows下完美echo客户端

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
// echo_selectserv_win.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <winsock2.h>

#define BUF_SIZE 1024

void ErrorHandling(char *message);

int main(int argc, char *argv[])
{
WSADATA wsaData;
SOCKET hServSock, hClntSock;
SOCKADDR_IN servAdr, clntAdr;
TIMEVAL timeout;
fd_set reads, cpyReads;

int adrSz;
int strLen, fdNum, i;
char buf[BUF_SIZE];

if (argc != 2) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}

// Windows 套接字初始化
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error!");

// 创建服务端套接字
hServSock = socket(PF_INET, SOCK_STREAM, 0);
memset(&servAdr, 0, sizeof(servAdr));
servAdr.sin_family = AF_INET;
servAdr.sin_addr.s_addr = htonl(INADDR_ANY);
servAdr.sin_port = htons(atoi(argv[1]));

// 绑定+监听
if (bind(hServSock, (SOCKADDR*)&servAdr, sizeof(servAdr)) == SOCKET_ERROR)
ErrorHandling("bind() error");
if (listen(hServSock, 5) == SOCKET_ERROR)
ErrorHandling("listen() error");

// 初始化 select 监视集合
FD_ZERO(&reads);
FD_SET(hServSock, &reads);

while (1)
{
// 备份集合(和Linux逻辑一致)
cpyReads = reads;
timeout.tv_sec = 5;
timeout.tv_usec = 0;

// Windows select:第一个参数固定填0
fdNum = select(0, &cpyReads, 0, 0, &timeout);
if (fdNum == SOCKET_ERROR)
break;
if (fdNum == 0)
continue;

// Windows 遍历方式:遍历 fd_count(核心差异)
for (i = 0; i < reads.fd_count; i++)
{
if (FD_ISSET(reads.fd_array[i], &cpyReads))
{
// 1. 服务端套接字:新客户端连接
if (reads.fd_array[i] == hServSock)
{
adrSz = sizeof(clntAdr);
hClntSock = accept(hServSock, (SOCKADDR*)&clntAdr, &adrSz);
FD_SET(hClntSock, &reads); // 此处fd_count++
printf("connected client: %d\n", hClntSock);
}
// 2. 客户端套接字:读取数据并回声
else
{
strLen = recv(reads.fd_array[i], buf, BUF_SIZE - 1, 0);
// 客户端断开连接
if (strLen == 0)
{
// 从集合删除+关闭套接字
FD_CLR(reads.fd_array[i], &reads);
closesocket(reads.fd_array[i]);
printf("closed client: %d\n", reads.fd_array[i]);
}
else
{
// 回声回传
send(reads.fd_array[i], buf, strLen, 0);
}
}
}
}
}

// 资源释放
closesocket(hServSock);
WSACleanup();
return 0;
}

// 错误处理函数
void ErrorHandling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

示例结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 服务端
# .\echo_selectserv_win.exe 9527
connected client: 240
closed client: 240

# 客户端
# .\client_echo2_win.exe 127.0.0.1 9527
Connected........
Input message(Q to quit): hello
Message from server: hello
Input message(Q to quit): how are u?
Message from server: how are u?
Input message(Q to quit): 鸡你太美
Message from server: 鸡你太美
Input message(Q to quit): Q

Chapter13 多种I/O函数

前面网络编程中一直使用通用文件 I/O 的 readwrite 传输数据,但这两个函数并非专为网络套接字设计。
本章聚焦网络编程专属I/O函数,补充 Linux 下多样化的数据读写方案,并对比跨平台差异:

  1. 首先讲解网络标准收发函数 send / recv,对比普通 read/write,学习专属参数、操作标志、网络独有特性,理解网络通信与普通文件读写的区别;
  2. 接着介绍 readv / writev 分散/聚集 I/O,支持多块缓冲区一次性读写,减少系统调用次数,解决零散数据拼接、分段传输的效率问题;
  3. 最后结合全书惯例,补充 Windows 平台对应的函数实现与差异适配,统一双平台网络I/O开发逻辑。

本章是基础套接字读写的进阶延伸,从「通用文件读写」过渡到「专业网络读写」,贴合实际工程开发的编码规范。

send & recv 函数

我们在 Windows 下已经用过 send/recv,Linux 下函数原型、核心逻辑完全一致,仅依赖头文件不同;

相比通用的 read/write,它们多了一个功能标志位(flags),支持带外数据、非阻塞、预读取等高级网络特性,是网络编程的标准读写函数。

1
2
3
4
5
6
#include <sys/socket.h>

// 发送数据
ssize_t send(int sockfd, const void *buf, size_t nbytes, int flags);
// 接收数据
ssize_t recv(int sockfd, void *buf, size_t nbytes, int flags);

Linux 中 send(sock, buf, len, 0) 等价于 write(sock, buf, len); recv(sock, buf, len, 0) 等价于 read(sock, buf, len)

参数 含义
sockfd 套接字文件描述符(客户端/服务端套接字)
buf 发送/接收数据的缓冲区地址
nbytes 要传输的最大字节数
flags 核心! 传输特性标志(可组合使用,传 0 表示无特殊特性)

返回值含义:

  1. 正整数:成功传输的实际字节数
  2. 0:仅 recv 会返回,代表对方关闭了连接(EOF)
  3. -1:函数调用发生错误

flags 参数可选值是 send/recv 区别于普通读写的关键,支持 5 个常用选项:

标志位 适用函数 含义与作用
MSG_OOB * 传输带外数据(Out-of-Band),发送 / 接收紧急数据
MSG_PEEK recv 预读取数据:从缓冲区读取数据,但不删除已读内容,下次调用仍能读到相同数据
MSG_DONTROUTE send 不经过路由表:直接向本地局域网目标发送数据,仅用于网络调试/特殊场景
MSG_DONTWAIT * 非阻塞模式:函数调用时无数据/无法发送,立即返回-1,不阻塞等待
MSG_WAITALL recv 等待全部数据:直到读取到指定的 nbytes 字节,才会返回,不提前退出
0 * 日常默认写法,没啥特殊动作

补充说明:

  1. 标志组合:多个标志可以用 | 拼接,例如 MSG_DONTWAIT | MSG_PEEK
  2. 默认用法:日常网络通信直接传 0,等同于 read/write
  3. 使用场景:紧急消息、非阻塞通信、数据预读取时,才需要开启特殊标志

MSG_OOB发送紧急数据(带外),只能传输最后一字节,OOB含义是Out-Of-Band,即通过完全不同的通信路径传输数据,但是TCP不另外提供,只能用紧急模式(Urgent Mode)进行传输。

这种方式基本已经废弃,现在利用普通数据流 + 协议内标记(比如消息头里加一个type=urgent字段)。

原书 MSG_OOB 代码示例(在我的 windows + wsl2 跑不出效果):

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
// flags_oob_send.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#define BUF_SIZE 30

void error_handling(char *message);

int main(int argc, char *argv[])
{
int sock;
struct sockaddr_in recv_adr;

if(argc!=3) {
printf("Usage :%s <IP> <port>\n", argv[0]);
exit(1);
}

sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&recv_adr, 0, sizeof(recv_adr));
recv_adr.sin_family = AF_INET;
inet_pton(AF_INET, argv[1], &recv_adr.sin_addr);
recv_adr.sin_port=htons(atoi(argv[2]));

if(connect(sock,(struct sockaddr*)&recv_adr,sizeof(recv_adr))==-1)
error_handling("connect() error!");

write(sock, "123", strlen("123"));
send(sock,"4", strlen("4"), MSG_OOB);
write(sock, "567", strlen("567"));
send(sock,"890", strlen("890"), MSG_OOB);

close(sock);
return 0;
}

void error_handling(char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(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
// flags_oob_recv.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#define BUF_SIZE 30

void error_handling(char *message);
void urg_handler(int signo);

// 全局变量:信号处理函数需要访问
int acpt_sock;
int recv_sock;

int main(int argc, char *argv[])
{
struct sockaddr_in recv_adr, serv_adr;
int str_len, state;
socklen_t serv_adr_sz;
struct sigaction act;
char buf[BUF_SIZE];

if ( argc!=2 ) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}

// 注册 SIGURG 信号处理函数
act.sa_handler = urg_handler;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;

acpt_sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&recv_adr, 0, sizeof(recv_adr));
recv_adr.sin_family = AF_INET;
recv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
recv_adr.sin_port = htons(atoi(argv[1]));

if(bind(acpt_sock, (struct sockaddr*)&recv_adr, sizeof(recv_adr))==-1)
error_handling("bind() error");
listen(acpt_sock, 5);

serv_adr_sz = sizeof(serv_adr);
recv_sock = accept(acpt_sock, (struct sockaddr*)&serv_adr, &serv_adr_sz);

// 设置套接字所有权为当前进程(下文解释)
fcntl(recv_sock, F_SETOWN, getpid());
// 注册紧急消息信号
state = sigaction(SIGURG, &act, 0);

// 循环接收普通数据
while((str_len = recv(recv_sock, buf, sizeof(buf), 0)) != 0)
{
if(str_len == -1)
continue;
buf[str_len] = 0;
puts(buf);
}

close(recv_sock);
close(acpt_sock);
return 0;
}

// 紧急消息处理函数(SIGURG信号触发)
void urg_handler(int signo)
{
int str_len;
char buf[BUF_SIZE];
// 接收带外数据
str_len = recv(recv_sock, buf, sizeof(buf) - 1, MSG_OOB);
buf[str_len] = 0;
printf("Urgent message: %s\n",buf);
}

void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

windows + wsl2 运行结果:

1
2
3
4
5
6
# 终端1
./flags_obb_recv 9527
# 终端2
./flags_obb_send 127.0.0.1 9527
# 终端1打印
normal: 123456789

虽然这个示例在我的环境测试不生效,但还是要解释一下。

在 Linux/Unix 系统中,当套接字收到带外数据(OOB,即紧急数据)时,内核需要一个目标进程来发送 SIGURG(紧急信号)通知。下面的代码就是告诉内核:“如果这个套接字有紧急数据进来,请把信号发给我(getpid() 返回的进程ID)”。如果不设置,内核不知道该给谁发信号,信号处理函数永远不会被触发。

1
2
3
4
// 设置套接字主人为当前进程  
fcntl(recv_sock, F_SETOWN, getpid());
// 然后注册 SIGURG 信号处理函数
sigaction(SIGURG, &act, 0);

之前我们触发信号(比如超时信号)都是由当前进程产生的,所以不需要设置主人。

下面看看MSG_PEEK搭配MSG_DONTWAIT预读取输入缓冲中的数据,即能读缓冲区内容但是不清缓冲区。

代码示例:

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
// flags_peek_send.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>

void error_handling(char *message);

int main(int argc, char *argv[])
{
int sock;
struct sockaddr_in recv_adr;

if(argc!=3) {
printf("Usage :%s <IP> <port>\n", argv[0]);
exit(1);
}

sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&recv_adr, 0, sizeof(recv_adr));
recv_adr.sin_family = AF_INET;
inet_pton(AF_INET, argv[1], &recv_adr.sin_addr);
recv_adr.sin_port=htons(atoi(argv[2]));

if(connect(sock, (struct sockaddr*)&recv_adr, sizeof(recv_adr))==-1)
error_handling("connect() error!");

write(sock, "123", strlen("123"));
close(sock);
return 0;
}

void error_handling(char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(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
// flags_peek_recv.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <fcntl.h>

#define BUF_SIZE 30
void error_handling(char *message);

int main(int argc, char *argv[])
{
int acpt_sock, recv_sock;
struct sockaddr_in acpt_adr, recv_adr;
int str_len, state;
socklen_t recv_adr_sz;
char buf[BUF_SIZE];

if ( argc!=2 ) {
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}

acpt_sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&acpt_adr, 0, sizeof(acpt_adr));
acpt_adr.sin_family = AF_INET;
acpt_adr.sin_addr.s_addr = htonl(INADDR_ANY);
acpt_adr.sin_port = htons(atoi(argv[1]));

if(bind(acpt_sock, (struct sockaddr*)&acpt_adr, sizeof(acpt_adr))==-1)
error_handling("bind() error");
listen(acpt_sock, 5);

recv_adr_sz = sizeof(recv_adr);
recv_sock = accept(acpt_sock, (struct sockaddr*)&recv_adr, &recv_adr_sz);

while(1)
{
str_len = recv(recv_sock, buf, sizeof(buf) - 1, MSG_PEEK | MSG_DONTWAIT);
if (str_len > 0) break;
}

// pre read
buf[str_len] = 0;
printf("Bufferring %d bytes: %s\n", str_len, buf);

// read again
str_len = recv(recv_sock, buf, sizeof(buf) - 1, MSG_PEEK | MSG_DONTWAIT);
buf[str_len] = 0;
printf("Read(recv) again. Bufferring %d bytes: %s\n", str_len, buf);

close(recv_sock);
close(acpt_sock);
return 0;
}

void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

运行结果:

1
2
3
4
5
6
7
# 终端 1
./flags_peek_recv 9527
# 终端 2
./flags_peek_send 127.0.0.1 9527
# 终端 1 输出
Bufferring 3 bytes: 123
Read(recv) again. Bufferring 3 bytes: 12

可见发送的数据被读取了两次,其中第一次为预读取,只是读取内容,不清楚缓冲区,第二次读取才清缓冲区。

readv & writev 函数

readvwritev分散/聚集 I/O 函数,专为多段不连续数据设计。

相比普通 read/write 只能操作单块缓冲区,它们可以一次性读写多块内存,无需手动拼接数据,大幅减少系统调用次数,是高性能网络编程的常用函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <sys/uio.h>

// 核心结构体:描述一块数据缓冲区
struct iovec {
void *iov_base; // 数据缓冲区的起始地址
size_t iov_len; // 缓冲区的长度(字节)
};

// 分散读:从文件/套接字读数据,放入多块缓冲区
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);

// 聚集写:将多块缓冲区数据,一次性写出
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);

这两个函数不依赖套接字,文件、管道、套接字都能使用;writev 是网络编程中发送「协议头+消息体」的最优方案

参数 含义
fd 文件/套接字描述符(通用型,可用于网络通信)
iov struct iovec 结构体数组,存放多块数据的地址+长度
iovcnt 结构体数组的元素个数(即要操作的缓冲区数量

返回值含义

  1. 正整数:成功读写的总字节数
  2. 0readv 专用,代表读到文件末尾/对方关闭连接
  3. -1:函数调用发生错误

核心功能

  • readv 分散读:一次读取,自动拆分存入多个不连续缓冲区
  • writev 聚集写:多段零散数据,一次性合并发送,无需拼接内存

writev代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// writev.c
#include <stdio.h>
#include <sys/uio.h>

int main() {
struct iovec vec[2];
int str_len;
char buf1[] = "ABCDEFG";
char buf2[] = "1234567";

vec[0].iov_base = buf1;
vec[0].iov_len = 3;
vec[1].iov_base = buf2;
vec[1].iov_len = 4;

str_len = writev(1, vec, 2);
puts(""); // 换行
printf("Write bytes length: %d\n", str_len);

return 0;
}

writev运行结果:

1
2
3
./writev
ABC1234
Write bytes length: 7

readv代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// readv.c
#include <stdio.h>
#include <sys/uio.h>

#define BUF_SIZE 100

int main() {
struct iovec vec[2];
int str_len;
char buf1[BUF_SIZE] = {0, };
char buf2[BUF_SIZE] = {0, };

vec[0].iov_base = buf1;
vec[0].iov_len = 5;
vec[1].iov_base = buf2;
vec[1].iov_len = BUF_SIZE;

str_len = readv(0, vec, 2);
printf("Read bytes length: %d\n", str_len);
printf("Read first buf: %s\n", buf1);
printf("Read second buf: %s\n", buf2);

return 0;
}

readv运行结果:

1
2
3
4
5
./readv
hello world hihihi i'm lee
Read bytes length: 27
Read first buf: hello
Read second buf: world hihihi i'm lee

总结就是能用就用,可以减少writeread调用次数,何乐而不为呢?

基于 Windows 的实现(处理MSG_OOB消息)

Windows 没有 Linux 下的 SIGURG 信号机制,无法通过触发信号来处理 TCP 紧急消息;但 select 模型支持监听异常文件描述符集合,而通过 Out-of-Band 传输的 MSG_OOB 紧急数据,会被系统判定为异常事件,因此我们完全可以依靠 select 的异常监听来统一处理紧急消息。

代码示例(send没什么差别,主要差别在recv):

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
// oob_send_win.c
#include <stdio.h>
#include <stdlib.h>
#include <winsock2.h>
#include <ws2tcpip.h>

#define BUF_SIZE 30
void ErrorHandling(char* message);

int main(int argc, char** argv) {
WSADATA wsaData;
SOCKET hSocket;
SOCKADDR_IN sendAddr;

if (argc != 3) {
printf("Usage: %s <ip> <port>\n", argv[0]);
exit(1);
}

if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error!");

hSocket = socket(PF_INET, SOCK_STREAM, 0);
if (hSocket == INVALID_SOCKET)
ErrorHandling("socket() error!");

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

if (connect(hSocket, (SOCKADDR*)&sendAddr, sizeof(sendAddr)) == SOCKET_ERROR)
ErrorHandling("connect() error!");

send(hSocket, "123", 3, 0);
send(hSocket, "4", 1, MSG_OOB);
send(hSocket, "567", 3, 0);
send(hSocket, "890", 3, MSG_OOB);

closesocket(hSocket);
WSACleanup();
return 0;
}

void ErrorHandling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(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
// oob_recv_win.c
#include <stdio.h>
#include <stdlib.h>
#include <winsock2.h>

#define BUF_SIZE 30
void ErrorHandling(char* message);

int main(int argc, char* argv[]) {
WSADATA wsaData;
SOCKET hRecvSock, hAcptSock;
SOCKADDR_IN recvAddr, sendAddr;
int sendAddrSize, strLen;
char buf[BUF_SIZE];
int result;

fd_set read, except, readCopy, exceptCopy;
struct timeval timeout;

if (argc != 2) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}

if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error!");

hAcptSock = socket(PF_INET, SOCK_STREAM, 0);
if (hAcptSock == INVALID_SOCKET)
ErrorHandling("socket() error!");

memset(&recvAddr, 0, sizeof(recvAddr));
recvAddr.sin_family = AF_INET;
recvAddr.sin_addr.S_un.S_addr = htonl(INADDR_ANY);
recvAddr.sin_port = htons(atoi(argv[1]));

if (bind(hAcptSock, (SOCKADDR*)&recvAddr, sizeof(recvAddr)) == SOCKET_ERROR)
ErrorHandling("bind() error!");

if (listen(hAcptSock, 5) == SOCKET_ERROR)
ErrorHandling("listen() error!");

sendAddrSize = sizeof(sendAddr);
hRecvSock = accept(hAcptSock, (SOCKADDR*)&sendAddr, &sendAddrSize);
if (hRecvSock == INVALID_SOCKET)
ErrorHandling("accept() error!");

FD_ZERO(&read);
FD_ZERO(&except);
FD_SET(hRecvSock, &read);
FD_SET(hRecvSock, &except);

while (1) {
readCopy = read;
exceptCopy = except;
timeout.tv_sec = 5;
timeout.tv_usec = 0;

result = select(0, &readCopy, 0, &exceptCopy, &timeout);

if (result > 0) {
// 监听到异常消息
if (FD_ISSET(hRecvSock, &exceptCopy)) {
strLen = recv(hRecvSock, buf, BUF_SIZE - 1, MSG_OOB);
buf[strLen] = 0;
printf("Urgent Msg: %s\n", buf);
}

// 监听到普通消息
if (FD_ISSET(hRecvSock, &readCopy)) {
strLen = recv(hRecvSock, buf, BUF_SIZE - 1, 0);
if (strLen == 0) {
break;
closesocket(hRecvSock);
} else {
buf[strLen] = 0;
puts(buf);
}
}
}
}

closesocket(hAcptSock);
WSACleanup();

return 0;
}

void ErrorHandling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

运行结果:

1
2
3
4
5
6
7
8
# 终端1
.\oob_recv_win.exe 9527
# 终端2
.\oob_send_win.exe 127.0.0.1 9527
# 终端1 输出
Urgent Msg: 4
12356789
Urgent Msg: 0

感觉这玩意儿比发信号好使多了,倒不如都用select。

然后就是,windows里面没有和writevreadv功能相似的函数,可以通过重叠I/O达成目的,先了解一下有这么个实现方式。

Chapter14 多播与广播

多播

多播基于UDP实现一对多传输,使用专用D类多播IP;

发送端只需创建UDP套接字、设置TTL(Time To Live,控制传输距离),直接向多播IP发送数据即可;

接收端必须绑定对应端口,通过结构体加入目标多播组(指定组IP+接收网卡),才能正常接收数据。

广播

广播同样基于UDP,分有限广播与子网广播,仅局限当前局域网,路由器不会转发广播数据;

发送端需创建UDP套接字,开启广播权限选项,直接向广播地址发送数据即可,无需结构体、不用加入任何群组;

接收端只需普通UDP绑定对应端口,无需额外配置,局域网内所有监听该端口的主机都能收到广播消息。

基于Windows实现

(用到时再细看吧,这一章就先这样过了)

Part2 基于Linux的编程(ch15~ch18)

Chapter15 套接字和标准I/O

创建套接字会获得缓冲(内核管理的,tcp错误重传需要,当然不止如此,udp也有),标准I/O自带缓冲区,主要为了性能提升,当我们使用标准I/O和套接字的时候,就有了一个双层缓冲的数据传输模型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>

// 打开/关闭
FILE *fopen(const char *path, const char *mode);
int fclose(FILE *stream);

// 格式化读写(最常用)
int fprintf(FILE *stream, const char *format, ...);
int fscanf(FILE *stream, const char *format, ...);

// 字节块读写(游戏/网络发数据用)
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);

// 行读写
char *fgets(char *str, int num, FILE *stream);
int fputs(const char *str, FILE *stream);

标准I/O函数的优缺点

优点主要就是攒着数据,减少系统调用和数据发送量(发的包多头信息数据量就多了),从而提升性能。

缺点有三

  • 不容易双向通信
  • 可能需要频繁调用fflush刷新缓冲区
  • 需要FILE*结构体指针返回文件描述符

使用标准I/O函数

fdopen 把文件描述符变成 FILE*,就能用标准 I/O 收发数据;用 fileno 能随时拿回原始文件描述符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <sys/socket.h>

int main() {
// 1. 创建套接字(获得文件描述符)
int sockfd = socket(AF_INET, SOCK_STREAM, 0);

// fdopen:套接字描述符 → 标准I/O流
FILE* fp = fdopen(sockfd, "r+"); // r+:读写模式

// 有了fp就可以用标准I/O了
// ...

// fileno:标准I/O流 → 还原套接字描述符
int original_fd = fileno(fp);

printf("原套接字fd: %d \n还原后fd: %d\n", sockfd, original_fd);
fclose(fp);
return 0;
}

基于套接字的标准I/O函数使用

套接字也是文件描述符,代码示例:

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
// echo_stdserver.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(char* message);

int main(int argc, char** argv) {
int serv_sock, clnt_sock;
char message[BUF_SIZE];
int i;
struct sockaddr_in serv_addr, clnt_addr;
socklen_t clnt_addr_size;

FILE* readfp;
FILE* writefp;

if (argc != 2) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}

serv_sock = socket(PF_INET, SOCK_STREAM, 0);
if (serv_sock == -1)
error_handling("socket() error!");

memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = PF_INET;
serv_addr.sin_port = htons(atoi(argv[1]));
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);

if (bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("bind() error!");

if (listen(serv_sock, 5) == -1)
error_handling("listen() error!");

clnt_addr_size = sizeof(clnt_addr);

for (i = 0; i < 5; i++) {
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_addr, &clnt_addr_size);
if (clnt_sock == -1)
error_handling("accept() error!");
else
printf("Connected client %d\n", i+1);

readfp = fdopen(clnt_sock, "r");
writefp = fdopen(clnt_sock, "w");
while(!feof(readfp)) {
fgets(message, BUF_SIZE, readfp);
fputs(message, writefp);
fflush(writefp); // 刷新缓冲区(立即传输数据)
}

fclose(readfp);
fclose(writefp);
}

close(serv_sock);
return 0;
}

void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(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
// echo_stdclient.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h> // write read 声明的地方
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(char* message);

int main(int argc, char** argv) {
int clnt_sock;
char message[BUF_SIZE];
int str_len;
struct sockaddr_in serv_addr;

FILE* readfp;
FILE* writefp;

if (argc != 3) {
printf("Usage: %s <ip> <port>\n", argv[0]);
exit(1);
}

clnt_sock = socket(PF_INET, SOCK_STREAM, 0);
if (clnt_sock == -1)
error_handling("socket() error!");

memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = PF_INET;
serv_addr.sin_port = htons(atoi(argv[2]));
if (inet_aton(argv[1], &serv_addr.sin_addr) == 0) // 会自动转换字节序
error_handling("inet_aton() error!");

// 建立连接
if (connect(clnt_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("connect() error!");
else
puts("Connected........");

readfp = fdopen(clnt_sock, "r");
writefp = fdopen(clnt_sock, "w");
while(1) {
fputs("Input message(Q to quit): ", stdout);
fgets(message, BUF_SIZE, stdin);

if (!strcmp(message, "q\n") || !strcmp(message, "Q\n"))
break;

fputs(message, writefp);
fflush(writefp); // 刷新缓冲区,立即传输
fgets(message, BUF_SIZE, readfp);
printf("Message from server: %s", message);
}

// fclose之后自动关闭套接字
fclose(readfp);
fclose(writefp);
return 0;
}

void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

代码运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 终端1
./echo_stdserver 9527
Connected client 1

# 终端2
./echo_stdclient 127.0.0.1 9527
Connected........
Input message(Q to quit): hello
Message from server: hello
Input message(Q to quit): hi
Message from server: hi
Input message(Q to quit): 鸡你太美
Message from server: 鸡你太美
Input message(Q to quit): q

Chapter16 关于I/O流分离的其他内容

分离I/O流

我们已经学过两种分离IO流的方式,第10章的创建子进程,让两个进程一个只读,一个只写;第15章的fdopen,以只读、只写的方式把文件描述符复制转换为FILE*

但是我们还需要进行数据流的半关闭,半关闭的好处:

  1. 只关闭单向数据流,保留另一方向的通信能力,适配 TCP 请求 - 响应模型(发完数据还能接收回复)。
  2. 向对端发送 EOF 结束标志(只有关闭写流发送,表示”我这边数据发完了”),明确通知对方「数据已发送完毕」,避免对方永久阻塞等待。
  3. 比直接 close 更安全可控,不会丢失缓冲区中未传输完成的数据。
  4. 适配 I/O 流分离场景,精准关闭读 / 写流,不影响另一独立流的工作。

多进程分离IO流我们可以用 shutdown 对文件描述符的流进行半关闭,但是转换成 FILE* 的只能用fclose,可fclose只需调用一次(即fclose(read或者write FILE*)),套接字便会彻底关闭,发送EOF。

说白了,一个套接字只有一个文件描述符,FILE指针就是个指针,分离读写的俩指针指向同一个文件描述符,我们fclose其中一个就会销毁文件描述符,相当于close(fd),从而使得套接字彻底被关闭,发送EOF。

下面我们就来解决这个问题(并非是简单用 filenoFILE* 转换回fd再shutdown)。

文件描述符的复制和半关闭

为了实现半关闭,我第一反应是是否可以这样:

1
2
3
4
5
6
7
8
9
10
FILE* readfp = fdopen(sock, "r");
FILE* writefp = fdopen(sock, "w");

// 此处使用writefp

shutdown(fileno(writefp), SHUT_WR);

// 此处使用readfp

fclose(writefp); // 或 fclose(readfp),总之只调用一次fclose销毁句柄

答案是代码能跑,但是资源管理存在风险,工程实用性弱。

理由很简单,一是 FILE* 指针会占用一部分用户态内存,理应释放,也就是 fclose 应该调用两次释放两个指针;二是 readfp 只能在半关闭写流之后,fclose之前使用,设计太脆弱,风险大。

况且,看见 FILE* 没释放,兴许哪位机智的小伙伴顺手给你再fclose,吊,对句柄双重释放,风险剧增,到时候有什么bug了你就查吧。

所以,我们要做到资源都能释放,fclose能调用两次,同时还得能半关闭读写流,我们就得复制一份句柄,让两个句柄指向同一个套接字内核对象,套接字销毁的条件是其句柄引用数量为0,这样便可以放心fclose销毁句柄了,只有都fclose了,才会销毁套接字,这完全符合我们的需要。

只是,句柄不能是相同的整数,好在我们有:

1
2
3
#include <unistd.h>
int dup(int oldfd);
int dup2(int oldfd, int newfd);

他们俩都能复制句柄,返回的新句柄和原来的指向同一个对象,其中 dup2 还能手动指定句柄值(当然有最大值限制,默认我们就控制在65535之内,实用,安心)。

这俩很类似,放一起说了:

参数

  • oldfd:被复制的原文件描述符
  • newfd:你手动指定想要的新文件描述符编号

返回值

  • 成功:返回系统当前最小可用的全新文件描述符 / 返回你指定的 newfd
  • 失败:返回 -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
// sep_serv2.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(char* message);

int main(int argc, char** argv) {
int serv_sock, clnt_sock;
FILE* readfp;
FILE* writefp;

struct sockaddr_in serv_addr, clnt_addr;
socklen_t clnt_addr_size;

char buf[BUF_SIZE] = {0, };

if (argc != 2) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}

serv_sock = socket(PF_INET, SOCK_STREAM, 0);
if (serv_sock == -1)
error_handling("socket() error!");

memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = PF_INET;
serv_addr.sin_port = htons(atoi(argv[1]));
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);

if (bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("bind() error!");

if (listen(serv_sock, 5) == -1)
error_handling("listen() error!");

clnt_addr_size = sizeof(clnt_addr);

clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_addr, &clnt_addr_size);
readfp = fdopen(clnt_sock, "r");
writefp = fdopen(dup(clnt_sock), "w"); // 复制句柄,现在俩指针所指的句柄不同,句柄引用的套接字相同

fputs("FROM SERVER: Hi~ client? \n", writefp);
fputs("FROM SERVER: I love u, listen... \n", writefp);
fputs("FROM SERVER: 鸡你太美~ \n", writefp);
fflush(writefp);

// 半关闭写流,表示发完了,释放指针销毁复制的句柄
shutdown(fileno(writefp), SHUT_WR); // 发送FIN包(EOF)
fclose(writefp);

// 继续从客户端读数据
fgets(buf, BUF_SIZE, readfp);
fputs(buf, stdout);
fclose(readfp); // 释放readfp,销毁句柄,引用计数为0,套接字销毁

// 记得close服务端套接字句柄
close(serv_sock);
return 0;
}

void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(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
// sep_client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(char* message);

int main(int argc, char** argv) {
int sock;
char buf[BUF_SIZE] = {0,};
struct sockaddr_in serv_addr;

FILE* readfp;
FILE* writefp;

if (argc != 3) {
printf("Usage: %s <ip> <port>\n", argv[0]);
exit(1);
}

sock = socket(PF_INET, SOCK_STREAM, 0);
if (sock == -1)
error_handling("socket() error!");

memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = PF_INET;
serv_addr.sin_port = htons(atoi(argv[2]));
if (inet_aton(argv[1], &serv_addr.sin_addr) == 0) // 会自动转换字节序
error_handling("inet_aton() error!");

// 建立连接
if (connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("connect() error!");
else
puts("Connected........");

readfp = fdopen(sock, "r");
writefp = fdopen(dup(sock), "w");
while(1) {
if (fgets(buf, BUF_SIZE, readfp) == NULL) {
break;
}

fputs(buf, stdout);
}

fputs("FROM CLIENT: Thank you! \n", writefp);
fflush(writefp);

fclose(writefp);
fclose(readfp);
return 0;
}

void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

运行结果:

1
2
3
4
5
6
7
8
9
# 终端1
./sep_serv2 9527
FROM CLIENT: Thank you!
# 终端2
./sep_client 127.0.0.1 9527
Connected........
FROM SERVER: Hi~ client?
FROM SERVER: I love u, listen...
FROM SERVER: 鸡你太美~

无论复制了多少文件描述符,都应该调用shutdown函数发送EOF并进入半关闭状态。

Chapter17 优于select的epoll

select 的三个主要缺点:

  1. 每次调用都要把全部文件描述符(fd)集合从用户态拷贝到内核态,fd 数量大时开销巨大。
  2. 内核检测就绪事件需要遍历所有 fd(O(n) 复杂度)。
  3. fd 数量上限通常为 1024(可通过宏修改,但仍有性能问题)。

epoll 的改进:

  • 内核维护一个 eventpoll 对象,每个 fd 只注册一次,无需重复拷贝。
  • 内核通过回调机制,当 fd 就绪时将其加入就绪链表,查询时直接返回就绪列表(O(1))。
  • 支持上万并发连接,且性能不随连接数增加而线性下降。

一句话:select 是轮询模型,epoll 是事件驱动模型

epoll 理解及应用

epoll_create

创建 epoll 内核事件表,现在尽量用epoll_create1(0)创建。

1
2
3
#include <sys/epoll.h>

int epoll_create(int size);
  • 作用:向内核申请一块空间(一般称为epoll例程),专门用来存放要监视的文件描述符
  • 返回值:成功返回 epoll 专用文件描述符(记做 epfd),失败 -1
  • size:推荐传大于0的数(Linux 2.6后忽略,仅作提示)
epoll_ctl

注册/删除/修改操作->监视对象(增删改)。

1
2
3
4
5
6
7
8
#include <sys/epoll.h>

int epoll_ctl(
int epfd, // epoll_create返回的句柄
int op, // 要执行的操作:ADD(注册)/DEL(删除)/MOD(修改)
int fd, // 要监视的套接字fd
struct epoll_event *event // 监视的事件类型(读/写/触发方式)
);
  • 核心作用:把需要监视的fd,注册到内核事件表里,只注册一次!
  • 常用操作:(看下面说明)
操作 含义 场景
EPOLL_CTL_ADD 新增:把fd加入epoll监视 第一次注册套接字
EPOLL_CTL_DEL 删除:把fd从epoll移除 断开连接、不再监视
EPOLL_CTL_MOD 修改:改fd的监视规则 改监听事件/改触发方式

ADD 是第一次注册,MOD 是后期改配置,DEL 是删掉不看了

1
2
3
4
5
// epoll例程A中注册的文件描述符B,主要目的是监视参数C中发生的事件
epoll_ctl(A, EPOLL_CTL_ADD, B, C);

// 从epoll例程A中删除文件描述符B
epoll_ctl(A, EPOLL_CTL_DEL, B, NULL);
epoll_wait

等待事件发生(阻塞等待)。

这个函数就相当于select的作用。

1
2
3
4
5
6
7
8
#include <sys/epoll.h>

int epoll_wait(
int epfd,
struct epoll_event *events, // 存放【发生事件的fd】的数组
int maxevents, // 最大事件数
int timeout // 超时时间
);
  • 返回值:发生事件的fd数量
  • 特点:只返回有事件的fd,不用遍历全部
重要结构体和示例

struct epoll_event,这是用户态与内核交互的唯一结构体,定义如下:

1
2
3
4
5
6
7
8
9
10
11
struct epoll_event {
uint32_t events; // 事件类型(EPOLLIN, EPOLLOUT, EPOLLET...)
epoll_data_t data; // 用户数据,通常放文件描述符
};

typedef union epoll_data {
void *ptr;
int fd; // 最常用:存放套接字描述符
uint32_t u32;
uint64_t u64;
} epoll_data_t;
  • events:告诉内核你想监控什么事件(可读、可写、错误、边缘触发等)。
  • data.fd:当事件就绪时,epoll_wait 会把这个 fd 返回给你,方便知道是哪个描述符就绪。

常见 events 标志:

标志 含义 使用场景
EPOLLIN 可读(数据到达或对端关闭连接导致 read 返回 0) 最常见
EPOLLOUT 可写(发送缓冲区有空闲) 一般仅在发送缓冲区满时等可写事件,否则会频繁触发
EPOLLPRI 带外数据(OOB)到达 紧急数据,如 TCP 紧急指针
EPOLLRDHUP 对端关闭连接或半关闭(TCP 收到 FIN) 用于边缘触发下精确检测对端关闭,避免与普通读事件混淆
EPOLLERR 错误发生 通常无需主动注册,epoll 总会报告
EPOLLET 边缘触发模式 高效模式,配合非阻塞 IO 使用
EPOLLONESHOT 事件一次性触发,之后需重新注册 防止同一个 fd 在多个线程中同时处理,常用于多线程服务器

示例:添加一个 fd(EPOLL_CTL_ADD

1
2
3
4
5
6
7
8
int epfd = epoll_create(1);
int listen_fd = socket(...); // 已 bind/listen

struct epoll_event ev;
ev.events = EPOLLIN; // 监听可读事件(默认条件触发 LT)
ev.data.fd = listen_fd; // 保存 fd,方便后续识别

epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

示例:修改已注册的 fd(EPOLL_CTL_MOD

比如原本只监听读,现在同时监听读和写:

1
2
3
ev.events = EPOLLIN | EPOLLOUT;   // 增加可写事件
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_MOD, client_fd, &ev);

示例:删除一个 fd(EPOLL_CTL_DEL

当连接关闭时,从 epoll 中移除:

1
2
epoll_ctl(epfd, EPOLL_CTL_DEL, client_fd, NULL);   // event 传 NULL
close(client_fd);

注意:如果直接 close(fd),内核会自动从 epoll 实例中移除该 fd,但显式调用 EPOLL_CTL_DEL 是更严谨的做法。

epoll 标准使用流程
  1. epoll_create 创建 epoll 句柄
  2. epoll_ctl 把服务端/客户端套接字 注册到事件表
  3. epoll_wait 阻塞等待事件发生
  4. 遍历返回的事件数组,只处理有事件的fd
epoll回声服务端示例

客户端我们使用5.1 echo客户端的完美实现

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
// echo_epollserv.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>

#define BUF_SIZE 100
#define EPOLL_SIZE 50
void error_handling(char* message);

int main(int argc, char* argv[]) {
int serv_sock, clnt_sock;
struct sockaddr_in serv_addr, clnt_addr;
socklen_t addr_size;
int str_len, i;
char buf[BUF_SIZE] = {0,};

struct epoll_event* ep_events;
struct epoll_event event;
int epfd, event_cnt;

if (argc !=2 ) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}

serv_sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(atoi(argv[1]));

if (bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("bind() error.\n");
if (listen(serv_sock, 5) == -1)
error_handling("listen() error.\n");

epfd = epoll_create1(0); // 现代函数,传0即可
ep_events = malloc(sizeof(struct epoll_event) * EPOLL_SIZE);

event.events = EPOLLIN; // 读取数据的事件
event.data.fd = serv_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);

while (1) {
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
if (event_cnt == -1) {
puts("epoll_wait() error!\n");
break;
}

for (i = 0; i < event_cnt; ++i) {
// 遍历检查触发事件的fd
if (ep_events[i].data.fd == serv_sock) {
// 有client连接
addr_size = sizeof(clnt_addr);
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_addr, &addr_size);

event.events = EPOLLIN;
event.data.fd = clnt_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
printf("connected client: %d\n", clnt_sock);
} else {
str_len = read(ep_events[i].data.fd, buf, BUF_SIZE);
if (str_len == 0) { // close request
epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
close(ep_events[i].data.fd);
printf("closed client: %d\n", ep_events[i].data.fd);
} else {
write(ep_events[i].data.fd, buf, str_len); // echo!
}
}
}
}

close(serv_sock);
close(epfd);

return 0;
}

void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 终端1
./echo_epollserv 9527
connected client: 5
closed client: 5
^C
# 终端2
./client_echo2 127.0.0.1 9527
Connected........
Input message(Q to quit): hello
Message from server: hello
Input message(Q to quit): 鸡你太美
Message from server: 鸡你太美
Input message(Q to quit): oh
Message from server: oh
Input message(Q to quit): baby
Message from server: baby
Input message(Q to quit): Q

条件触发和边缘触发

条件触发(LT,Level Trigger),边缘触发(ET,Edge Trigger)。

(根据我稀稀拉拉的数电知识,条件触发感觉翻译成电平触发更容易理解)

条件触发和边缘触发是 epoll 通知事件的方式,而事件(Event),就是 fd 状态变化,比如“有数据可读”(EPOLLIN)、“可写”(EPOLLOUT)。

条件触发(LT)是,只要输入缓冲(以输入缓冲为例)中有数据,就会通知该事件,哪怕读取一次之后,只要输入缓冲中还有数据,就还会注册事件;而边缘触发就不是这样,它只会在输入缓冲收到数据时注册一次事件,后续便不会再注册。

观察条件触发(LT),我们可以将上面的epoll回声服务端的 BUF_SIZE 改为 2,客户端发送 4 字节数据(包含回车符,也就是三个字母+一个回车符)。在 LT 模式下,由于每次 read 最多只能取走 2 字节,缓冲区会剩余 2 字节,因此仍会注册新事件,导致 epoll_wait 连续返回两次(分别读取前 2 字节和后 2 字节)。这就是条件触发:只要缓冲区还有数据,事件就会持续上报。

也就是说,我们默认是条件触发的,设置边缘触发(ET),只需要:

1
event.events = EPOLLIN | EPOLLET;

观察边缘触发,我们只需要在之前修改的基础上再修改触发方式,然后发送数据,会发现无论发送多少字节数据都只会打印一次信息。

但是运行后客户端发生错误:

1
2
3
4
5
6
7
8
9
10
# 终端1
./echo_epollserv 9527
return epoll_wait # 客户端连接时打印
connected client: 5
return epoll_wait # 客户端发送数据时打印
# 终端2
./client_echo2 127.0.0.1 9527
Connected........
Input message(Q to quit): hello
(卡在这儿,没输出也不能再输入)

下面我们要实现边缘触发的回声服务端,首先要知道两点:

  • 通过 errno 变量验证错误原因;
  • 为了完成非阻塞I/O,更改套接字特性。

errno是调用套接字操作函数返回错误时产生的错误号,他可以帮我们定位出错的大致原因,在头文件 error.h 中。

设置非阻塞我们要用到 fctnl,之前也用过:

1
2
3
4
#include <unistd.h>
#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* arg */ );

常用参数(cmd)

  • F_GETFL:获取文件状态标志。
  • F_SETFL:设置文件状态标志。

返回值

  • 成功:取决于 cmd(例如 F_GETFL 返回标志值,F_SETFL 返回 0)。
  • 失败:返回 -1,并设置 errno。

设置非阻塞,直接在原有的标志中加上 O_NONBLOCK

1
2
3
4
int flags = fcntl(fd, F_GETFL, 0);      // 获取标志
if (flags == -1) error();
flags |= O_NONBLOCK; // 添加非阻塞标志
if (fcntl(fd, F_SETFL, flags) == -1) error();

边缘触发说明和示例

始终牢记,在边缘触发(ET)模式下,内核只在文件描述符状态从无变为有的那一刻通知一次(客户端发起连接,或者客户端发来数据的那一刻)。这意味着:

我们之前做的测试,接受两个字节的buf被read填满之后就再次执行到epoll_wait,缓冲区还有数据,但是不会再注册事件,永久阻塞在那儿了,所以一直没server echo打印。

解决方法是循环读,把输入缓冲区的数据读干净。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
while (1) {
int n = read(fd, buf, sizeof(buf));
if (n == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK)
break; // 数据已读完,安全退出循环
else
// 错误处理
break;
} else if (n == 0) {
// 对端关闭连接
close(fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
break;
} else {
write(fd, buf, n); // 处理数据(如回显)
}
}

但是循环读我们会遇到可能阻塞的问题,例如:客户端发送 “hello”(5字节,BUF_SIZE为2字节)。

  1. 第一次 read:读 2 字节(”he”)→ 返回 2。
  2. 第二次 read:读 2 字节(”ll”)→ 返回 2。
  3. 第三次 read:读 1 字节(”o”)→ 返回 1。
  4. 第四次 read:缓冲区已空,由于 fd 是阻塞模式,read 会永远阻塞,等待新数据到达。线程卡死,无法处理其他 fd。

这说明我们在使用ET触发方式时,要做到:

  • 我们必须在一个事件通知内,尽可能多地把数据读完(或写完),要用到循环读/写,直到底层返回 EAGAIN(表示暂时没有数据/缓冲区已满)。
  • 为此还必须将文件描述符设置为非阻塞,否则最后一次 read/write 如果没有数据可读/写,就会阻塞住整个线程,破坏事件循环。
  • 同时需要检查 errno 来判断是否真的因为没有数据而停止:read/write 返回 -1 且 errno == EAGAIN(或 EWOULDBLOCK)时,才说明当前已经处理完毕,可以继续等待下一次 epoll 通知。

简单总结:ET + 非阻塞 + 检查 EAGAIN 是经典三板斧,缺一不可。

epoll ET触发方式echo服务端示例(客户端使用5.1 echo客户端的完美实现):

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
// echo_EPETserv.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <errno.h>

#define BUF_SIZE 4
#define EPOLL_SIZE 50

void setnonblockingmode(int fd);
void error_handling(char* message);

int main(int argc, char* argv[]) {
int serv_sock, clnt_sock;
struct sockaddr_in serv_addr, clnt_addr;
socklen_t addr_size;
int str_len, i;
char buf[BUF_SIZE] = {0,};

struct epoll_event* ep_events;
struct epoll_event event;
int epfd, event_cnt;

if (argc !=2 ) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}

serv_sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(atoi(argv[1]));

if (bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("bind() error.\n");
if (listen(serv_sock, 5) == -1)
error_handling("listen() error.\n");

epfd = epoll_create1(0); // 现代函数,传0即可
ep_events = malloc(sizeof(struct epoll_event) * EPOLL_SIZE);

//
// 原书有这行,把serv_sock设置非阻塞,但没必要
// 如果 serv_sock 采用 EPOLLIN|EPOLLET,就必须:
// 1. 设置 serv_sock 为非阻塞
// 2. 在事件处理中循环 accept 直到返回 EAGAIN。
// serv_scok的事件通知用条件触发这里好得很
// setnonblockingmode(serv_sock);
event.events = EPOLLIN; // 读取数据的事件
event.data.fd = serv_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, serv_sock, &event);

while (1) {
event_cnt = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
if (event_cnt == -1) {
puts("epoll_wait() error!\n");
break;
}

puts("return epoll_wait");
for (i = 0; i < event_cnt; ++i) {
// 遍历检查触发事件的fd
if (ep_events[i].data.fd == serv_sock) {
// 有client连接
addr_size = sizeof(clnt_addr);
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_addr, &addr_size);
setnonblockingmode(clnt_sock); // 数据可能一次无法读全,避免阻塞
event.events = EPOLLIN | EPOLLET;
event.data.fd = clnt_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sock, &event);
printf("connected client: %d\n", clnt_sock);
} else {
while (1) {
str_len = read(ep_events[i].data.fd, buf, BUF_SIZE);
if (str_len == 0) { // close request
epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events[i].data.fd, NULL);
close(ep_events[i].data.fd);
printf("closed client: %d\n", ep_events[i].data.fd);
break;
} else if (str_len == -1) {
if (errno == EAGAIN) {
break;
}
} else {
write(ep_events[i].data.fd, buf, str_len); // echo!
}

}
}
}
}

close(serv_sock);
close(epfd);

return 0;
}

void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

void setnonblockingmode(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) {
error_handling("Get flags failed!");
}
flags |= O_NONBLOCK;
if (fcntl(fd, F_SETFL, flags) == -1) {
error_handling("Set flags failed!");
}
}

运行结果:

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
# 终端1
./echo_EPETserv 9527
return epoll_wait
connected client: 5
return epoll_wait
connected client: 6
return epoll_wait
return epoll_wait
return epoll_wait
return epoll_wait
return epoll_wait
return epoll_wait
closed client: 6
return epoll_wait
closed client: 5
^C

# 终端2
./client_echo2 127.0.0.1 9527
Connected........
Input message(Q to quit): hello
Message from server: hello
Input message(Q to quit): 鸡
Message from server: 鸡
Input message(Q to quit): 太
Message from server: 太
Input message(Q to quit): Q

# 终端3
./client_echo2 127.0.0.1 9527
Connected........
Input message(Q to quit): 你
Message from server: 你
Input message(Q to quit): 美
Message from server: 美
Input message(Q to quit): Q

连接时打印一次消息,发送任意字节数据时打印一次消息(return epoll_wait)。

条件触发和边缘触发没有绝对优劣

(书中举例是服务端要将ABC客户机发来的消息按序拼接)

边缘触发(ET):事件只通知一次,可以选择“先不读”,等条件满足再读。这样能灵活控制数据处理顺序,不会被重复事件打扰。

条件触发(LT):只要缓冲区有数据,就会一直通知。如果故意延迟读取,每次 epoll_wait 都会立即返回,导致 CPU 忙转(事件累加),服务器无法承受。所以 LT 下不适合延迟处理数据。

但是条件触发和边缘触发没有绝对优劣,选择主要看服务端模型的设计

边缘触发的好处是:事件通知次数少,可以通过循环读写,在一次通知内把数据尽可能处理完。这需要自己在代码中控制处理的节奏,但能做到更高的效率。

条件触发虽然简单,但如果处理不及时,输入缓冲区可能会被填满,造成数据延迟。不过条件触发不会丢失事件,编程更安全。

Chapter18 多线程服务器端的实现

Web服务发展迅猛,迫使Linux重视线程的使用,原本是Windows中应用更广泛。

多线程服务器端典型模型:

graph LR
    subgraph 主线程
        M[创建 socket
bind/listen] A[accept 获得 client_fd] end subgraph 线程池 T1[工作线程 1] T2[工作线程 2] T3[工作线程 ...] end M --> A A -->|分发 client_fd| T1 A -->|分发 client_fd| T2 A -->|分发 client_fd| T3 T1 -->|read/write| C1[客户端1] T2 -->|read/write| C2[客户端2] T3 -->|...| C3[客户端...]

理解线程的概念

进程是资源分配的最小单位,线程是 CPU 调度的最小单位。

  • 进程:拥有独立的地址空间(代码、数据、堆)、文件描述符、信号处理等资源。进程之间互不干扰。
  • 线程:共享所属进程的大部分资源(代码、数据、堆),只私有自己的栈和寄存器上下文(包括程序计数器 PC)。

之前讲过的多进程服务,存在进程上下文切换这种很耗费资源的操作,因为每个进程都有独立的空间,切换时就要把前一个释放干净,把下一个加载进来。

而线程大部分数据都是共享的,切换上下文只需要切换自己的栈空间。

操作系统内核、进程和线程的关系好比是:

  • 进程 = 一座房子(有独立的厨房、卧室、卫生间)。
  • 线程 = 房子里的人(共享房子里的所有资源,但每个人有自己的私人日记本(栈)和当前做的事情(寄存器/PC))。
  • 操作系统内核 = 管家,负责决定哪个人在什么时候做什么事(调度)。
graph TD
    subgraph 操作系统 OS
        K[内核调度器]
    end

    subgraph 进程 A
        A_Mem[地址空间
代码段/数据段/堆] subgraph A_Threads[线程] A_T1["线程 1
栈1 + PC/寄存器"] A_T2["线程 2
栈2 + PC/寄存器"] end end subgraph 进程 B B_Mem[地址空间
代码段/数据段/堆] B_T["线程
栈 + PC/寄存器"] end K -->|调度| A_T1 K -->|调度| A_T2 K -->|调度| B_T A_T1 -.共享.-> A_Mem A_T2 -.共享.-> A_Mem B_T -.共享.-> B_Mem

线程创建及运行

线程是一个可单独调度的执行单位,创建它好比打开了一个应用(需要可执行函数),只是这个应用通常只处理很小的事务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <pthread.h>

int pthread_create(pthread_t * restrict thread, // 输出:线程ID
const pthread_attr_t * restrict attr, // 线程属性(一般填NULL)
void *(*start_routine)(void*), // 线程要执行的函数(相当于线程的main函数)
void *arg); // 传给该函数的参数

// 阻塞直到线程结束(销毁线程,回收资源)
int pthread_join(pthread_t thread, // 该参数值的线程ID终止后才会从函数返回
void **status); // 保存线程main函数返回值的指针变量地址

// 将指定的线程标记为 detached(分离)状态。该线程终止时,系统会自动回收其所有资源。
// 一旦分离,不能再调用pthread_join。
int pthread_detach(pthread_t thread);

编译:链接库加上pthread

返回值:成功时返回0,失败时返回其他值。

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// pthread_test1.c
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

void* print_numbers(void* arg) {
int start = *(int*)arg;
for (int i = 0; i < 3; ++i) {
printf("thread: %d\n", start + i);
sleep(1);
}

return NULL;
}

int main(int argc, char* argv[]) {
pthread_t tid;
int val = 100;
pthread_create(&tid, NULL, print_numbers, &val);
pthread_join(tid, NULL); // 阻塞等待线程结束
return 0;
}

这个代码并不是书上源码,书上为了讲清细节一开始用个sleep跑,不太了解的话可以去看看。

线程执行只需要create就行了,随后(可能会过一段时间)便会开始执行。

线程终止需要调用 pthread_joinpthread_detach 回收资源,不然只是结束,线程创建的内存空间会一直存在。

线程生命周期流程图:

sequenceDiagram
    participant Main as 主线程
    participant Kernel as 内核调度器
    participant New as 新线程

    Main->>Kernel: pthread_create()
    Note over Kernel: 创建线程控制块
分配栈空间 Kernel-->>Main: 返回 tid (线程已就绪) Note over Main: 主线程继续执行
(可能调用 pthread_join) Kernel->>New: 调度运行 New->>New: 执行线程函数 New->>New: return 或 pthread_exit() Note over New: 线程终止,进入“僵尸”状态
等待被回收 alt 已调用 pthread_join Main->>Kernel: pthread_join(tid) Kernel-->>Main: 回收线程资源,返回退出码 else 已设置 detach 状态 Kernel->>Kernel: 自动回收线程资源 end

线程存在的问题和临界区

临界区问题是多线程编程的核心:多个线程访问(这里的访问指修改)共享资源时需要互斥(静态数据,全局变量)。

我们编写多线程程序的时候,使用函数也应该用线程安全版本(可以在编译参数加上-D_REENTRANT让编译器自动替换掉线程不安全函数)。

我们看一段代码:

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
// thread4.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>

// 线程总数
#define NUM_THREAD 100

// 全局共享变量(多线程竞争访问)
long long num = 0;

// 线程函数声明
void *thread_inc(void *arg); // 累加线程
void *thread_des(void *arg); // 递减线程

int main(int argc, char *argv[])
{
pthread_t thread_id[NUM_THREAD];
int i;

// 打印long long类型大小(8字节=64位)
printf("sizeof long long: %lu\n", sizeof(long long));

// 创建线程:奇数创建累加线程,偶数创建递减线程
for (i = 0; i < NUM_THREAD; i++)
{
if (i % 2)
pthread_create(&thread_id[i], NULL, thread_inc, NULL);
else
pthread_create(&thread_id[i], NULL, thread_des, NULL);
}

// 等待所有线程执行完毕
for (i = 0; i < NUM_THREAD; i++)
pthread_join(thread_id[i], NULL);

// 打印最终结果(无锁,结果一定错误)
printf("result: %lld\n", num);
return 0;
}

// 累加线程函数:num +1 执行50万次
void *thread_inc(void *arg)
{
int i;
for (i = 0; i < 500000; i++)
num += 1;
return NULL;
}

// 递减线程函数:num -1 执行50万次
void *thread_des(void *arg)
{
int i;
for (i = 0; i < 500000; i++)
num -= 1;
return NULL;
}

这个代码分了50个线程递增,50个线程递减,都对全局变量num进行操作,主线程中只是打印结果,理论上结果为0很符合人脑直觉,但实际结果并非如此(如果你的运行结果得到0,试着把50万改大很多)。

执行结果:

1
2
3
4
5
6
7
./thread4
sizeof long long: 8
result: 349259

./thread4
sizeof long long: 8
result: -704769

产生上面结果的原因也很简单,线程1访问全局变量的时候,在其内部完成运算还未返回结果,被线程2访问了。比如说,num值是0,原本应该被两个线程做一次加1和减1的操作,我们预期中正常流程应该是:

  1. 线程1取到值0;
  2. 线程1将值加1;
  3. 线程1将结果返回(值1);
  4. 线程2取到值1;
  5. 线程2将值减1;
  6. 线程2将结果返回(值0)。

最后得到结果0。

但实际情况可能在第2步的时候进行了第4步,导致线程1和线程2取到了一样的,未更新过的值0,返回的值不是1就是-1了。

拿全局变量举例只是个直观的说法,实际上任何内存空间被多线程同时访问(更改)都可能出现问题。

临界区的定义

书中说是:函数内运行多个线程时,引起问题的多条语句构成的代码块

我们要解决这个问题,就要找到临界区的位置:

1
2
3
4
5
6
7
8
9
int i;
for (i = 0; i < 500000; i++)
num += 1; // 临界区
return NULL;

int i;
for (i = 0; i < 500000; i++)
num -= 1; // 临界区
return NULL;

产生问题的原因是线程1执行+1的同时线程2在-1,但前提是都在对同意内存空间数据进行操作。

下面讲解决方法。

(实际上还是访问同一内存空间。。。书上免不了有各种繁文缛节)

线程同步

上面讲了两个线程同时访问同一内存空间可能产生的问题,还有一种情况是两个线程需要有固定执行顺序的问题,比如线程1和2,一个写数据,一个读数据,这时候先写后读和先读后写可能需要严格把控,两种顺序对后续操作产生的结果可能完全不同。

我们需要使用线程同步解决这些问题,下面讲解**互斥量(Mutex)信号量(Semaphore)**这两种同步技术。

互斥量:避免同时访问

解决线程同步访问的问题。

方便理解,想象一下下面这段对话,你和你朋友要上撤硕:

1
2
3
4
5
你:里边有人没有?
朋友:干嘛?在拉史。

你:好好好,gkd。
朋友:马上好,我夹断。

这里怎么解决同时访问的呢?说白了就是上撤硕要锁门,不然你憋了个大的直接冲进去,不乱了套了吗。

在多线程开发里,临界区就是上撤硕(修改共享资源的操作叫临界区),互斥量就是门锁。下面介绍怎么使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <pthread.h>

// 对于静态分配的互斥量(全局或 static 局部),可以不用 init/destroy
// 这种方式等效于调用 pthread_mutex_init(mutex, NULL),且无需显式销毁。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

// 初始化、销毁互斥量
// mutex:指向互斥量变量的指针
// attr:互斥量属性(通常传 NULL,表示默认属性)
// 返回值:成功返回 0,失败返回错误码(非零)
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);

// 加锁,如果互斥量已被其他线程锁定,则阻塞直到解锁。
// 解锁,由持有锁的线程调用,释放锁。若有线程在等待,将唤醒其中一个。
// 返回值:0成功,非零失败。
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

// 尝试加锁(非阻塞),若锁已被占用,立即返回错误码 EBUSY,不会阻塞。
int pthread_mutex_trylock(pthread_mutex_t *mutex);

我们解决上述示例的同步问题:

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
// mutex.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>

#define NUM_THREAD 100

long long num = 0;
pthread_mutex_t mutex; // 互斥量

void *thread_inc(void *arg); // 累加线程
void *thread_des(void *arg); // 递减线程

int main(int argc, char *argv[])
{
pthread_t thread_id[NUM_THREAD];
int i;

pthread_mutex_init(&mutex, NULL); // 创建

printf("sizeof long long: %lu\n", sizeof(long long));

for (i = 0; i < NUM_THREAD; i++)
{
if (i % 2)
pthread_create(&thread_id[i], NULL, thread_inc, NULL);
else
pthread_create(&thread_id[i], NULL, thread_des, NULL);
}

for (i = 0; i < NUM_THREAD; i++)
pthread_join(thread_id[i], NULL);

printf("result: %lld\n", num);
pthread_mutex_destroy(&mutex); // 销毁

return 0;
}

void *thread_inc(void *arg)
{
int i;
pthread_mutex_lock(&mutex);
for (i = 0; i < 500000; i++)
num += 1; // 临界区
pthread_mutex_unlock(&mutex);
return NULL;
}

void *thread_des(void *arg)
{
int i;
for (i = 0; i < 500000; i++) {
pthread_mutex_lock(&mutex);
num -= 1; // 临界区
pthread_mutex_unlock(&mutex);
}
return NULL;
}

执行结果:

1
2
3
./mutex  
sizeof long long: 8
result: 0

很简单吧,创建,然后在临界区前加锁,在临界区后解锁,销毁,就这么点东西…对于简单的内容而言很简单hhh…

如果忘记解锁,那么别的先线程来访问资源发现还被锁着,用不了,这种情况就是“死锁”。

上面我们在递增函数线程中扩大了临界区,用lockunlock囊括住了整个循环,这是为了减少上锁和解锁调用次数,但是这样的话,一个线程跑完50万次运算前另一个就一直等着资源不跑了,这相当于没有用到多线程;但实际上在这个例子中减少锁操作是对的,因为上锁解锁的开销很大,放在循环内不停上锁解锁反而更慢。

在真实的项目开发中要作取舍,扩不扩大临界区要看情况。

信号量:控制线程顺序

信号量和互斥量很相似,后面只介绍”二进制信号量”(只用0和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
#include <semaphore.h>
// data type: sem_t

// 初始化
/*
sem:指向信号量变量的指针
pshared:0 表示在同一进程的各线程间共享;非零表示可在多个进程间共享,但此时信号量必须存放在共享内存区域(如通过 mmap 或 shm_open 分配)
value:信号量的初始计数值(资源可用数量),普通场景填 1 类似互斥锁,更大的值适合控制多个同类资源的访问
*/
int sem_init(sem_t *sem, int pshared, unsigned int value);

// 释放(V操作):将信号量的计数值原子地加 1。如果有线程阻塞在 sem_wait 上,唤醒其中一个。
// 不会阻塞,且可在信号处理函数中安全调用(异步信号安全),这是 POSIX 线程同步原语中唯一具备这一属性的函数。
int sem_post(sem_t *sem);

// 等待(P操作):若当前计数值 > 0,将其减 1 并立即返回;若计数值 == 0,阻塞调用线程直到计数值变为 > 0 并成功减 1。
int sem_wait(sem_t *sem);

// 销毁:销毁前必须确保没有线程在等待该信号量,否则行为未定义。
int sem_destroy(sem_t *sem);


// 尝试等待(非阻塞版本):若计数值 > 0,将其减 1 并立即返回 0;若计数值 == 0,立即返回 -1,errno 设置为 EAGAIN,不阻塞。
int sem_trywait(sem_t *sem);
// 带超时的等待版本,阻塞时间到达上限后仍未等到资源则返回 -1(了解即可)
sem_timedwait(sem_t *sem, const struct timespec *abs_timeout)

// 获取当前计数值:返回值一般始终是0
/*
sem:指向信号量
sval:传出参数,用于存储当前计数值
*/
int sem_getvalue(sem_t *sem, int *sval);

返回值:除了’wait’和’getvalue’,成功返回 0,失败返回 -1 并设置 errno。

sem_postsem_wait 一个为计数值+1,一个为计数值-1,计数值代表可供线程使用的资源数。
信号量不允洗小于0,若信号量值只在0和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
// semaphore.c
#include <stdio.h>
#include <pthread.h>
#include <semaphore.h>

// 线程函数声明
void *read_num(void *arg);
void *accu(void *arg);

// 全局信号量 + 共享变量
static sem_t sem_one;
static sem_t sem_two;
static int num;

int main(int argc, char *argv[])
{
pthread_t id_t1, id_t2;

// 初始化信号量:sem_one初始值0,sem_two初始值1
sem_init(&sem_one, 0, 0);
sem_init(&sem_two, 0, 1);

// 创建线程
pthread_create(&id_t1, NULL, read_num, NULL);
pthread_create(&id_t2, NULL, accu, NULL);

// 等待线程结束
pthread_join(id_t1, NULL);
pthread_join(id_t2, NULL);

// 销毁信号量
sem_destroy(&sem_one);
sem_destroy(&sem_two);

return 0;
}

// 输入数字的线程
void *read_num(void *arg)
{
int i;
for (i = 0; i < 5; i++)
{
fputs("Input num: ", stdout);
sem_wait(&sem_two); // 等待sem_two可用(初始化为1,所以一开始就可用)
scanf("%d", &num); // 输入数字
sem_post(&sem_one); // 通知累加线程
}
return NULL;
}

// 累加求和的线程
void *accu(void *arg)
{
int sum = 0, i;
for (i = 0; i < 5; i++)
{
sem_wait(&sem_one); // 等待输入完成(初始化为0,需要等到另一个线程先跑给他资源)
sum += num; // 累加数字
sem_post(&sem_two); // 通知输入线程
}
printf("RESULT: %d\n", sum); // 输出结果
return NULL;
}

执行结果:

1
2
3
4
5
6
7
./semaphore                
Input num: 5
Input num: 2
Input num: 3
Input num: 4
Input num: 1
RESULT: 15

这个示例中,一个线程负责接收用户输入,另一个线程获取前面线程接收的值并累加,用二进制信号量严格控制他们的执行顺序。

多线程服务器端的实现-echo

做一个echo服务端,客户端用5.1 echo客户端的完美实现

代码示例:

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
// pthread_echo_serv.c
/*
多线程echo服务器

1. 主线程创建监听 socket,等待客户端连接。
2. 每来一个客户端,就创建一个新线程去处理它(读取数据并原样发回)。
3. 客户端断开后线程自动结束。(客户端使用client_echo2)
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>
#include <arpa/inet.h>

#define BUF_SIZE 1024
void error_handling(char* message);
void *handle_client(void* arg);

int main(int argc, char* argv[]) {
int serv_sock, clnt_sock;
struct sockaddr_in serv_addr, clnt_addr;
socklen_t clnt_addr_len = sizeof(clnt_addr);

if (argc !=2 ) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}

serv_sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(atoi(argv[1]));

if (bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("bind() error.\n");
if (listen(serv_sock, 5) == -1)
error_handling("listen() error.\n");

while (1) {
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_addr, &clnt_addr_len);

// 为每个客户端创建一个线程
pthread_t tid;
// 动态分配空间,传递clnt_sock
int *pclient = malloc(sizeof(int));
*pclient = clnt_sock;
// 传递clnt_sock必须要新的地址空间,请看下面的时间线
/*
主线程 子线程(尚未运行)
---------- ----------------
client_fd = 100
pthread_create(&tid, &client_fd) [参数记录了地址 &client_fd]
(线程就绪,但未跑)
client_fd = 200(下一次 accept)
(终于被调度)
int fd = *(int*)arg;
// 读到 200,而不是 100
*/
if (pthread_create(&tid, NULL, handle_client, pclient) != 0) {
perror("pthread_create");
close(clnt_sock);
free(pclient);
} else {
pthread_detach(tid); // 标记线程为可分离状态,不阻塞代码执行,线程结束后自动回收资源,不需要join
}
}

close(serv_sock);
return 0;
}

void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

void *handle_client(void* arg) {
int client_fd = *(int*)arg;
free(arg);
char buf[BUF_SIZE];
ssize_t n;

printf("New Client: %d\n", client_fd);

while( (n = read(client_fd, buf, sizeof(buf))) > 0 ) {
write(client_fd, buf, n);
}

close(client_fd);
printf("Client %d Closed.\n", client_fd);
return NULL; // 正常退出
}

多线程服务器端的实现-chat

我们接下来要做一个可以聊天的小程序,采用多线程技术。

需要注意的是,我们多线程访问全局变量,那块代码是临界区,需要用互斥量管理,如果不是多线程访问就不用了。

服务端代码:

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
// chat_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>
#include <arpa/inet.h>

#define BUF_SIZE 1024+2
#define MAX_CLNT 512
void* handle_clnt(void* arg);
void send_msg(char* msg, int len);
void error_handling(char* message);

int gClnt_cnt = 0;
int gClnt_socks[MAX_CLNT];
pthread_mutex_t mutex;

int main(int argc, char* argv[]) {
int serv_sock, clnt_sock;
struct sockaddr_in serv_addr, clnt_addr;
socklen_t clnt_addr_len = sizeof(clnt_addr);

pthread_t t_id;

if (argc !=2 ) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}

pthread_mutex_init(&mutex, NULL);
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(atoi(argv[1]));

if (bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("bind() error.\n");
if (listen(serv_sock, 5) == -1)
error_handling("listen() error.\n");

while(1) {
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_addr, &clnt_addr_len);

pthread_mutex_lock(&mutex);
gClnt_socks[gClnt_cnt++] = clnt_sock;
pthread_mutex_unlock(&mutex);

pthread_create(&t_id, NULL, handle_clnt, (void*)&clnt_sock);
pthread_detach(t_id);
printf("Connected client IP: %s \n", inet_ntoa(clnt_addr.sin_addr));

}

close(serv_sock);
return 0;
}

void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

void* handle_clnt(void* arg) {
int clnt_sock = *((int*)arg);
int str_len = 0;
int i;
char msg[BUF_SIZE];

while((str_len = read(clnt_sock, msg, BUF_SIZE)) != 0) {
send_msg(msg, str_len);
}

// disconnected, remove this client
pthread_mutex_lock(&mutex);
for (i = 0; i < gClnt_cnt; i++) {
if (clnt_sock == gClnt_socks[i]) {
while (i++ < gClnt_cnt - 1) {
gClnt_socks[i] = gClnt_socks[i+1];
}
break;
}
}
gClnt_cnt--;
pthread_mutex_unlock(&mutex);

close(clnt_sock);
return NULL;
}

void send_msg(char* msg, int len) {
int i;
pthread_mutex_lock(&mutex);
for(i = 0; i < gClnt_cnt; i++) {
write(gClnt_socks[i], msg, len);
}
pthread_mutex_unlock(&mutex);
}

客户端代码:

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
// chat_client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>
#include <arpa/inet.h>
#define BUF_SIZE 1024
#define NAME_SIZE 32

void* send_msg(void* arg);
void* recv_msg(void* arg);
void error_handling(char* message);

char gName[NAME_SIZE] = "[DEFAULT]";
char gMsg[BUF_SIZE];

int main(int argc, char* argv[]) {
int sock;
struct sockaddr_in serv_addr;
pthread_t send_thread, recv_thread;
void* thread_return;

if (argc != 4) {
printf("Usage: %s <ip> <port> <name>\n", argv[0]);
exit(1);
}

if (strlen(argv[3]) > 20) {
printf("name: %s, len: %ld\n", argv[3], strlen(argv[3]));
error_handling("Please input name length < 20 bytes.");
}

sprintf(gName, "%s", argv[3]);

sock = socket(PF_INET, SOCK_STREAM, 0);
if (sock == -1)
error_handling("socket() error!");

memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = PF_INET;
serv_addr.sin_port = htons(atoi(argv[2]));
if (inet_aton(argv[1], &serv_addr.sin_addr) == 0) // 会自动转换字节序
error_handling("inet_aton() error!");

// 建立连接
if (connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
error_handling("connect() error!");
else
puts("Connected........");

pthread_create(&send_thread, NULL, send_msg, (void*)&sock);
pthread_create(&recv_thread, NULL, recv_msg, (void*)&sock);
pthread_join(send_thread, &thread_return);
pthread_join(recv_thread, &thread_return);

close(sock);
return 0;
}

void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

void* send_msg(void* arg) {
int sock = *(int*)arg;
char name_msg[NAME_SIZE + BUF_SIZE];
while (1) {
fgets(gMsg, BUF_SIZE, stdin);
if (!strcmp(gMsg, "q\n") || !strcmp(gMsg, "Q\n")) {
close(sock);
exit(0);
}
// 这里增加了2个bytes在消息中,为了避免字符串截断,长度也+2
// 实际应该由程序解析消息格式,以此添加额外字符信息
snprintf(name_msg, sizeof(name_msg) + 4, "%s: %s", gName, gMsg);
write(sock, name_msg, strlen(name_msg));
}
return NULL;
}

void* recv_msg(void* arg) {
int sock = *(int*)arg;
char name_msg[NAME_SIZE + BUF_SIZE];
int str_len;
while (1) {
str_len = read(sock, name_msg, NAME_SIZE + BUF_SIZE - 1);
if (str_len == -1) {
return (void*)-1;
}
name_msg[str_len] = 0;
fputs("-> ", stdout);
fputs(name_msg, stdout);
}
return NULL;
}

执行结果:

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
# 终端1
./chat_server 9527
Connected client IP: 127.0.0.1
Connected client IP: 127.0.0.1

# 终端2
./chat_client 127.0.0.1 9527 gmq
Connected........
hi
-> gmq: hi
-> cola: 干嘛
不干嘛,就hi一下
-> gmq: 不干嘛,就hi一下
-> cola: 鸡你
太美?
-> gmq: 太美?
-> cola: yeah
q

# 终端3
./chat_client 127.0.0.1 9527 cola
Connected........
-> gmq: hi
干嘛
-> cola: 干嘛
-> gmq: 不干嘛,就hi一下
鸡你
-> cola: 鸡你
-> gmq: 太美?
yeah
-> cola: yeah
q

多线程不是为了“同时跑”,而是为了在单核上提高吞吐与响应(IO密集型应用,让CPU一直有事做),在多核上实现真正并行加速。

Part3 基于Windows的编程(ch19-ch23)

Chapter19 Windows平台下线程的使用

内核对象

  • 内核对象就是为了管理线程、文件等资源而由操作系统创建的数据块,其创建者和所有者均为操作系统。
  • 内核对象用于表示系统中的各种资源:进程、线程、文件、事件、互斥体、信号量、套接字(在 Windows 中 socket 也是一个内核对象)等。

基于Windows的线程创建

windows下有创建线程的函数:

1
2
3
4
5
6
7
8
9
#include <windows.h>
HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes, // 安全属性:NULL=默认,句柄不可继承
SIZE_T dwStackSize, // 栈大小:0=默认(1MB)
LPTHREAD_START_ROUTINE lpStartAddress, // 线程函数:函数名,格式 DWORD WINAPI func(LPVOID)
LPVOID lpParameter, // 传给线程函数的参数(任意指针)
DWORD dwCreationFlags, // 创建标志:0=立即运行,CREATE_SUSPENDED=挂起
LPDWORD lpThreadId // 传出线程ID:传变量的地址,不需要填NULL
); // 返回值 HANDLE,成功返回句柄,需 CloseHandle
1
2
3
4
5
6
7
8
9
10
#include <process.h>
// 更好的线程创建函数,内部调用 CreateThread
uintptr_t _beginthreadex(
void *security, // 安全属性:NULL=默认
unsigned stack_size, // 栈大小:0=默认
unsigned (__stdcall *start_address)(void *),// 线程函数:格式 unsigned __stdcall func(void*)
void *arglist, // 传给线程函数的参数
unsigned initflag, // 创建标志:0=立即运行,CREATE_SUSPENDED=挂起
unsigned *thrdaddr // 传出线程ID:传变量的地址,不需要填NULL
); // 返回值 uintptr_t,可强转为 HANDLE,成功需 CloseHandle

推荐使用 _beginthreadex。C/C++ 运行时库(CRT)中有一些函数使用了静态/全局变量(例如 errnostrtok 的内部指针、gmtime 的静态缓冲区)。如果直接用 CreateThread 创建线程,CRT 没有机会为这些变量分配线程局部存储(TLS),导致多线程环境下这些函数互相干扰、结果错乱或崩溃。

_beginthreadex 做的事情

  1. 在调用 CreateThread 之前,分配并初始化 CRT 所需的 TLS 索引。
  2. 调用 CreateThread 创建真正的系统线程。
  3. 在新线程启动时,自动设置正确的 TLS 数据,让每个线程拥有独立的 errnostrtok 状态等。

因此,只要你的线程函数中可能用到任何 CRT 函数(包括 printfmallocstrtokerrnofopen 等),就必须使用 _beginthreadex,否则可能出现难以调试的随机 bug。

纯 Windows API 编程(如只用 WriteFileMessageBox 等,完全不调用 CRT 函数)才可以用 CreateThread。但即便那样,C++ 中的 new / delete 也会调用 CRT,所以 C++ 线程也必须用 _beginthreadex

C++ 更推荐直接用 std::thread(它会自动选择合适的底层实现,在 Windows 上就是 _beginthreadex 的封装)。但如果你在纯 C 环境或需要直接控制底层句柄,用 _beginthreadex

使用 _beginthreadex 需要多线程版本的 C 运行时库。CMake 中根据编译器设置:

1
2
3
4
5
6
7
8
9
10
11
cmake_minimum_required(VERSION 3.16)
project(win_thread_demo C)

add_executable(thread_demo main.c)

if(MINGW)
target_compile_options(thread_demo PRIVATE -mthreads)
target_link_options(thread_demo PRIVATE -mthreads)
elseif(MSVC)
set_property(TARGET thread_demo PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
endif()

创建线程的代码示例:

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
// thread1_win.c
#include <windows.h>
#include <process.h> // _beginthreadex _endthreadex
#include <stdio.h>
unsigned WINAPI ThreadFunc(void* arg);

int main(int argc, char* argv[]) {
HANDLE hThread;
unsigned int threadID;
int param = 5;

hThread = (HANDLE)_beginthreadex(NULL, 0, ThreadFunc, (void*)&param, 0, &threadID);
if (hThread == 0) {
puts("_beginthreadex() error.");
return -1;
}
Sleep(3000);
puts("end of main.");
return 0;
}

unsigned WINAPI ThreadFunc(void* arg) {
int i;
int cnt = *((int*)arg);
for (i = 0; i < cnt; i++) {
Sleep(1000);
puts("running thread.");
}

return 0;
}

执行结果:

1
2
3
running thread.
running thread.
end of main.

windows下也是main函数返回后终止进程,同时终止所有包含其中的线程,所以没有5次打印,当然,打印 running thread. 也可能在 end of main. 之后,这发生在main函数返回但进程还未完全销毁的时候。

句柄可以区分内核对象,内核对象可以区分线程,最终,线程句柄成为区分线程的工具。而最后一个参数,线程ID,用于区分操作系统内所有线程(不同进程中句柄可能重复,但线程ID不会),但是一般没有这种需求。

内核对象的2种状态

资源类型不同,内核对象也含有不同信息,应用程序实现过程中需要关注信息被赋予的状态。

线程内核对象需要关注线程是否已经终止:终止状态(signaled状态),未终止状态(non-signaled状态)。

我们实现一个应用的时候,站在开发者角度,自然要考虑该进程或者线程何时终止,操作系统会将这些重要信息保存到内核对象,当进程或者线程终止时,操作系统会把相应的内核对象改为signaled状态。

注意:内核对象设置为signaled状态并不意味着线程结束,只是调用此对象的函数会立即返回。

内核对象用一个boolean变量表示这两种状态,初值为false,表示non-signaled状态,如果发生了约定的事件,变量就变为true,为signaled状态。

内核对象的类型不同,进入signaled状态的情况也有所区别,我们可用 WaitForSignleObjectWaitForMultipleObjects 验证内核对象signaled状态,它们是线程同步和等待内核对象状态改变的基础工具。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <windows.h>

DWORD WaitForSingleObject(
HANDLE hHandle, // 要等待的内核对象句柄
DWORD dwMilliseconds // 超时时间(毫秒)
);

DWORD WaitForMultipleObjects(
DWORD nCount, // 对象句柄数组的元素个数(最大 MAXIMUM_WAIT_OBJECTS,通常 64)
const HANDLE *lpHandles, // 对象句柄数组指针
BOOL bWaitAll, // TRUE = 等待所有对象有信号;FALSE = 等待任意一个有信号
DWORD dwMilliseconds // 超时时间
);
WaitForSingleObject 返回值 含义
WAIT_OBJECT_0 对象变为有signaled状态(例如线程退出、事件被触发、互斥体可用)
WAIT_TIMEOUT 超时时间内对象一直无信号
WAIT_FAILED 调用失败(可调用 GetLastError() 查看原因)
WAIT_ABANDONED 等待的互斥体被拥有它的线程终止前未释放(仅用于互斥体)

WaitForSingleObject函数,发生事件(变为signaled状态)返回时,有时会把内核对象再次改为 non-signaled 状态,这种可以再次进入 non-signaled 状态的内核对象称为,”auto-reset模式” 的内核对象;反之称为 “manual-reset模式” 的内核对象。

WaitForMultipleObjects 返回值

  • 如果 bWaitAll == TRUE:
    • 成功:返回 WAIT_OBJECT_0(所有对象都有信号)。
    • 超时:WAIT_TIMEOUT。
  • 如果 bWaitAll == FALSE:
    • 成功:返回值 = WAIT_OBJECT_0 + 满足条件的对象在数组中的索引(例如第二个对象满足,则返回 WAIT_OBJECT_0 + 1)。
    • 超时:WAIT_TIMEOUT。
  • 同样可能返回 WAIT_FAILED 或 WAIT_ABANDONED。

现在我们可以用等待函数 WaitForSingleObject 等一个线程结束了,示例代码:

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
// thread2_win.c
#include <windows.h>
#include <process.h> // _beginthreadex _endthreadex
#include <stdio.h>
unsigned WINAPI ThreadFunc(void* arg);

int main(int argc, char* argv[]) {
HANDLE hThread;
DWORD wr;
unsigned int threadID;
int param = 5;

hThread = (HANDLE)_beginthreadex(NULL, 0, ThreadFunc, (void*)&param, 0, &threadID);
if (hThread == 0) {
puts("_beginthreadex() error.");
return -1;
}
if ((wr = WaitForSingleObject(hThread, INFINITE)) == WAIT_FAILED) {
puts("thread wait error.");
return -1;
}

printf("wait result: %s \n", (wr == WAIT_OBJECT_0) ? "signaled" : "time-out");

puts("end of main.");
return 0;
}

unsigned WINAPI ThreadFunc(void* arg) {
int i;
int cnt = *((int*)arg);
for (i = 0; i < cnt; i++) {
Sleep(1000);
puts("running thread.");
}

return 0;
}

运行结果:

1
2
3
4
5
6
7
running thread.
running thread.
running thread.
running thread.
running thread.
wait result: signaled
end of main.

WaitForMultipleObjects 等一堆线程,示例代码:

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
// thread3_win.c
#include <windows.h>
#include <process.h> // _beginthreadex _endthreadex
#include <stdio.h>

#define NUM_THREAD 50
unsigned WINAPI ThreadInc(void* arg);
unsigned WINAPI ThreadDes(void* arg);
long long num = 0;

int main(int argc, char* argv[]) {
HANDLE tHandles[NUM_THREAD];
int i;

printf("sizeof long long: %d \n", sizeof(long long));
for (i = 0; i < NUM_THREAD; ++i) {
if (i % 2) {
tHandles[i] = (HANDLE)_beginthreadex(NULL, 0, ThreadInc, NULL, 0, NULL);
} else {
tHandles[i] = (HANDLE)_beginthreadex(NULL, 0, ThreadDes, NULL, 0, NULL);
}
}
WaitForMultipleObjects(NUM_THREAD, tHandles, TRUE, INFINITE);

printf("result: %lld\n", num);

return 0;
}

unsigned WINAPI ThreadInc(void* arg) {
int i;
for (i = 0; i < 500000; ++i) {
num += 1;
}
return 0;
}


unsigned WINAPI ThreadDes(void* arg) {
int i;
for (i = 0; i < 500000; ++i) {
num -= 1;
}
return 0;
}

运行结果:

1
2
3
\thread3_win.exe
sizeof long long: 8
result: -125619

多执行几次也是这个结果,没有同步(加锁、互斥,多个线程访问共享数据产生数据竞争。

如何理解signaled状态和non-signaled

可以把 signalednonsignaled 理解为 Windows 内核对象的 “红绿灯”

  • Signaled(有信号) = 🟢 绿灯:等待这个对象的线程可以立即通过(不阻塞)。
  • Nonsignaled(无信号) = 🔴 红灯:等待这个对象的线程必须停下来等待,直到对象变成绿灯。

不同对象“绿灯”的含义不同,取决于这个对象代表什么事件或资源:

对象类型 绿灯(Signaled)代表 红灯(Nonsignaled)代表
线程 / 进程 线程或进程已经结束(死亡) 正在运行中
互斥量 互斥量空闲(没有线程持有) 互斥量被某个线程占用
信号量 计数器 > 0,还有可用资源 计数器 == 0,资源耗尽
自动重置事件 事件发生(某件事已完成) 事件未发生(等待下一次触发)
手动重置事件 事件处于“已触发”状态 事件处于“未触发”状态
等待定时器 设定的时间已到 时间未到

所以 signaled/nonsignaled 只是一个抽象的“通过/等待”标志,具体含义取决于对象类型。对于线程对象,“绿灯”恰等于“终结”,但其他对象则不然。

一句话:Signaled 表示“条件满足,等待者可以继续”;Nonsignaled 表示“条件不满足,等待者必须等待”。

Chapter20 Windows中的线程同步

同步方法的分类及CRITICAL_SECTION同步

同步方法分用户模式,内核模式同步方法,内核对象创建资源过程都要经过用户->内核->用户,从用户模式切换到内核模式只是为了创建资源。

内核模式同步拥有功能更多,可以指定超时,防止产生死锁,还能跨进程进行线程同步,只是需要从用户切换到内核,性能有影响。

用户模式下CRITICAL_SECTION同步方法,代码示例:

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
// SyncCS_win.c
#include <windows.h>
#include <process.h> // _beginthreadex _endthreadex
#include <stdio.h>

#define NUM_THREAD 50
unsigned WINAPI ThreadInc(void* arg);
unsigned WINAPI ThreadDes(void* arg);
long long num = 0;
CRITICAL_SECTION cs;

int main(int argc, char* argv[]) {
HANDLE tHandles[NUM_THREAD];
int i;

// 初始化资源
InitializeCriticalSection(&cs);
for (i = 0; i < NUM_THREAD; ++i) {
if (i % 2) {
tHandles[i] = (HANDLE)_beginthreadex(NULL, 0, ThreadInc, NULL, 0, NULL);
} else {
tHandles[i] = (HANDLE)_beginthreadex(NULL, 0, ThreadDes, NULL, 0, NULL);
}
}
WaitForMultipleObjects(NUM_THREAD, tHandles, TRUE, INFINITE);
// 释放资源
DeleteCriticalSection(&cs);

printf("result: %lld\n", num);

return 0;
}

unsigned WINAPI ThreadInc(void* arg) {
int i;
// 持有key,逻辑类似加锁
EnterCriticalSection(&cs);
for (i = 0; i < 500000; ++i) {
num += 1;
}
// 上交key,逻辑类似解锁
LeaveCriticalSection(&cs);
return 0;
}

unsigned WINAPI ThreadDes(void* arg) {
int i;
EnterCriticalSection(&cs);
for (i = 0; i < 500000; ++i) {
num -= 1;
}
LeaveCriticalSection(&cs);

return 0;
}

执行结果:

1
2
.\Sync_CS.exe    
result: 0

内核模式的同步方法

典型的内核同步方法有:基于事件、信号量、互斥量等内核对象的同步。

基于互斥量(Mutex)的同步

互斥量(Mutex,Mutual Exclusion)是 Windows 提供的一个内核对象,用于协调多个线程对共享资源的互斥访问。与临界区(Critical Section)相比,互斥量可以跨进程使用,并且可以指定超时等待。

它和前面说过的CS类似,也可以用在等待函数中。

1. 创建互斥量:CreateMutex

1
2
3
4
5
HANDLE CreateMutex(
LPSECURITY_ATTRIBUTES lpMutexAttributes, // 安全属性,通常为 NULL
BOOL bInitialOwner, // 是否初始占有(TRUE=创建线程立即获得该互斥量)
LPCTSTR lpName // 命名(用于跨进程),不跨进程则为 NULL
);
  • 返回值:成功返回互斥量句柄(HANDLE),失败返回 NULL。
  • 参数说明
    • lpMutexAttributes:一般填 NULL 表示默认安全描述符且句柄不可继承。
    • bInitialOwner
      • TRUE:创建线程成为该互斥量的初始拥有者,其他线程必须等待该线程释放。
      • FALSE:互斥量初始为无主状态,任何线程都可以通过 WaitForSingleObject 获取。
    • lpName:给互斥量起一个名字(字符串),其他进程可通过相同名字打开同一互斥量。不需要跨进程时填 NULL

示例

1
2
3
4
HANDLE hMutex = CreateMutex(NULL, FALSE, NULL);   // 初始无主
if (hMutex == NULL) {
printf("CreateMutex failed: %d\n", GetLastError());
}

2. 释放互斥量:ReleaseMutex

1
BOOL ReleaseMutex(HANDLE hMutex);
  • 作用:释放当前线程对互斥量的所有权。只有当前拥有该互斥量的线程才能调用此函数,否则返回 FALSEGetLastError() 返回 ERROR_NOT_OWNER
  • 返回值:成功返回非零,失败返回 0。

典型使用模式

1
2
3
WaitForSingleObject(hMutex, INFINITE);   // 获取互斥量
// 临界区代码
ReleaseMutex(hMutex); // 释放

互斥量是”auto-reset”模式的内核对象,在WaitForSingleObject等待函数返回时自动进入 non-signaled 状态。

3. 关闭句柄:CloseHandle

互斥量是内核对象,使用完毕后必须关闭句柄,否则会泄漏内核对象。

1
BOOL CloseHandle(HANDLE hObject);

示例代码:

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
// SyncMutex_win.c
#include <windows.h>
#include <process.h> // _beginthreadex _endthreadex
#include <stdio.h>

#define NUM_THREAD 50
unsigned WINAPI ThreadInc(void* arg);
unsigned WINAPI ThreadDes(void* arg);

long long num = 0;
HANDLE hMutex;

int main(int argc, char* argv[]) {
HANDLE tHandles[NUM_THREAD];
int i;

hMutex = CreateMutex(NULL, FALSE, NULL);
for (i = 0; i < NUM_THREAD; ++i) {
if (i % 2) {
tHandles[i] = (HANDLE)_beginthreadex(NULL, 0, ThreadInc, NULL, 0, NULL);
} else {
tHandles[i] = (HANDLE)_beginthreadex(NULL, 0, ThreadDes, NULL, 0, NULL);
}
}
WaitForMultipleObjects(NUM_THREAD, tHandles, TRUE, INFINITE);
CloseHandle(hMutex);

printf("result: %lld\n", num);

return 0;
}

unsigned WINAPI ThreadInc(void* arg) {
int i;
WaitForSingleObject(hMutex, INFINITE);
for (i = 0; i < 500000; ++i) {
num += 1;
}
ReleaseMutex(hMutex);
return 0;
}

unsigned WINAPI ThreadDes(void* arg) {
int i;
WaitForSingleObject(hMutex, INFINITE);
for (i = 0; i < 500000; ++i) {
num -= 1;
}
ReleaseMutex(hMutex);

return 0;
}

运行结果:

1
2
.\SyncMutex_win.exe
result: 0
基于信号量的同步

信号量用于控制同时访问某个资源的线程数量。它维护一个内部计数器:

  • 计数器 > 0 时(进入signaled状态),等待函数立即返回,计数器减 1。
  • 计数器 == 0 时(进入non-signaled状态),等待函数阻塞。
  • ReleaseSemaphore 使计数器增加。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 创建信号量
HANDLE CreateSemaphore(
LPSECURITY_ATTRIBUTES lpSemaphoreAttributes, // 安全属性,通常 NULL
LONG lInitialCount, // 初始计数器值
LONG lMaximumCount, // 最大计数器值
LPCTSTR lpName // 命名(跨进程),不跨进程则 NULL
);

// 释放信号量(计数器 +1)
BOOL ReleaseSemaphore(
HANDLE hSemaphore,
LONG lReleaseCount, // 增加的数量,通常为 1
LPLONG lpPreviousCount // 返回之前的值,不需要则 NULL
);

// 等待信号量(计数器减1)用 WaitForSingleObject / WaitForMultipleObjects
// 关闭信号量:CloseHandle

代码示例:

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
// SyncSema_win.c

#include <stdio.h>
#include <windows.h>
#include <process.h>

unsigned WINAPI Read(void* arg);
unsigned WINAPI Accu(void* arg);

static HANDLE semOne;
static HANDLE semTwo;
static int num;

int main(int argc, char* argv[]) {
HANDLE hThread1, hThread2;
semOne = CreateSemaphore(NULL, 0, 1, NULL);
semTwo = CreateSemaphore(NULL, 1, 1, NULL);

hThread1 = (HANDLE)_beginthreadex(NULL, 0, Read, NULL, 0, NULL);
hThread1 = (HANDLE)_beginthreadex(NULL, 0, Accu, NULL, 0, NULL);

WaitForSingleObject(hThread1, INFINITE);
WaitForSingleObject(hThread2, INFINITE);

CloseHandle(semOne);
CloseHandle(semTwo);

return 0;
}

unsigned WINAPI Read(void* arg) {

int i;
for (i = 0; i < 5; ++i) {
fputs("Input num: ", stdout);
WaitForSingleObject(semTwo, INFINITE); // -1
scanf("%d", &num);
ReleaseSemaphore(semOne, 1, NULL); // +1
}
return 0;
}

unsigned WINAPI Accu(void* arg) {
int sum = 0, i;
for (i = 0; i < 5; ++i) {
WaitForSingleObject(semOne, INFINITE); // -1
sum += num;
ReleaseSemaphore(semTwo, 1, NULL); // +1
}
printf("Result: %d\n", sum);

return 0;
}

运行结果:

1
2
3
4
5
6
7
.\SyncSema_win.exe 
Input num: 9
Input num: 5
Input num: 2
Input num: 7
Input num: 0
Result: 23
基于事件的同步

事件用于线程间的通知。有两种类型:

  • **自动重置事件(Auto-reset):**当等待成功时,事件自动变为无信号状态。适合一对一的线程通知。
  • **手动重置事件(Manual-reset):**需要手动调用 ResetEvent 才能变为无信号。适合一对多的广播。

事件对象的主要特点就是可以创建Manual-reset模式的对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 创建事件
HANDLE CreateEvent(
LPSECURITY_ATTRIBUTES lpEventAttributes, // 安全属性
BOOL bManualReset, // TRUE = 手动重置,FALSE = 自动重置
BOOL bInitialState, // TRUE = 初始有信号,FALSE = 无信号
LPCTSTR lpName // 命名
);

// 将事件设为有信号状态
BOOL SetEvent(HANDLE hEvent);

// 将事件设为无信号状态(仅用于手动重置事件)
BOOL ResetEvent(HANDLE hEvent);

// 成功返回TRUE,失败FALSE

// 等待事件用 WaitForSingleObject / WaitForMultipleObjects
// 关闭事件:CloseHandle

代码示例,两个线程同时结束(无固定先后顺序):

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
// SyncEvent_win.c

#include <stdio.h>
#include <windows.h>
#include <process.h>
#define STR_LEN 100

unsigned WINAPI NumberOfA(void* arg);
unsigned WINAPI NumberOfOthers(void* arg);

static char str[STR_LEN];
static HANDLE hEvent;

int main(int argc, char* argv[]) {
HANDLE hThread1, hThread2;
hEvent = CreateEvent(NULL, TRUE, FALSE, NULL); // init non-signaled

hThread1 = (HANDLE)_beginthreadex(NULL, 0, NumberOfA, NULL, 0, NULL);
hThread1 = (HANDLE)_beginthreadex(NULL, 0, NumberOfOthers, NULL, 0, NULL);

fputs("Input string: ", stdout);
fgets(str, STR_LEN, stdin);
SetEvent(hEvent); // set to signaled

WaitForSingleObject(hThread1, INFINITE);
WaitForSingleObject(hThread2, INFINITE);

ResetEvent(hEvent);
CloseHandle(hEvent);

return 0;
}

unsigned WINAPI NumberOfA(void* arg) {
int i, cnt;
WaitForSingleObject(hEvent, INFINITE);
for (i = 0; str[i] != 0; ++i) {
if (str[i] == 'A') {
cnt++;
}
}

printf("Num of A: %d\n", cnt);

return 0;
}

unsigned WINAPI NumberOfOthers(void* arg) {
int i, cnt;
WaitForSingleObject(hEvent, INFINITE);
for (i = 0; str[i] != 0; ++i) {
if (str[i] != 'A') {
cnt++;
}
}

printf("Num of others: %d\n", cnt);

return 0;
}

执行结果:

1
2
3
4
5
6
7
8
9
.\SyncEvent_win.exe
Input string: A lonely boy has An Apple, bite it and chews... so juicy... he miss his family...
Num of A: 3
Num of others: 79

.\SyncEvent_win.exe
Input string: A lonely boy has An Apple, bite it and chews... so juicy... he miss his family...
Num of others: 79
Num of A: 3

Windows下实现多线程服务器端

服务端代码示例:

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
// chat_serv_win.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <winsock2.h>
#include <windows.h>
#include <process.h>

#define BUF_SIZE 100
#define MAX_CLNT 256

unsigned WINAPI HandleClnt(void* arg);
void SendMsg(char* msg, int len);
void ErrorHandling(char* message);

int clntCnt = 0;
SOCKET clntSocks[MAX_CLNT];
HANDLE hMutex;

int main(int argc, char* argv[]) {
WSADATA wsaData;
SOCKET hServSock, hClntSock;
SOCKADDR_IN servAddr, clntAddr;
int szClntAddr;
HANDLE hThread;

if (argc != 2) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}

if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error!");

hMutex = CreateMutex(NULL, FALSE, NULL);
hServSock = socket(PF_INET, SOCK_STREAM, 0);
if (hServSock == INVALID_SOCKET)
ErrorHandling("socket() error!");

memset(&servAddr, 0, sizeof(servAddr));
printf("servaddr size: %zu\n", sizeof(servAddr));
servAddr.sin_family = AF_INET;
servAddr.sin_addr.S_un.S_addr = htonl(INADDR_ANY);
servAddr.sin_port = htons(atoi(argv[1]));

if (bind(hServSock, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR)
ErrorHandling("bind() error!");

if (listen(hServSock, 5) == SOCKET_ERROR)
ErrorHandling("listen() error!");

while (1) {
szClntAddr = sizeof(clntAddr);
hClntSock = accept(hServSock, (SOCKADDR*)&clntAddr, &szClntAddr);
if (hClntSock == INVALID_SOCKET)
ErrorHandling("accept() error!");

WaitForSingleObject(hMutex, INFINITE);
clntSocks[clntCnt++] = hClntSock;
ReleaseMutex(hMutex);

hThread = (HANDLE)_beginthreadex(NULL, 0, HandleClnt, (void*)&hClntSock, 0, NULL);
printf("Connected client IP: %s \n", inet_ntoa(clntAddr.sin_addr));
}

closesocket(hServSock);
WSACleanup();

return 0;
}

void ErrorHandling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

unsigned WINAPI HandleClnt(void* arg) {
SOCKET hClntSock = *((SOCKET*)arg);
int strLen = 0, i;
char msg[BUF_SIZE];
while ((strLen = recv(hClntSock, msg, sizeof(msg), 0)) != 0) {
SendMsg(msg, strLen);
}

WaitForSingleObject(hMutex, INFINITE);
for (i = 0; i < clntCnt; ++i) {
if (hClntSock == clntSocks[i]) {
while (i++ < clntCnt - 1) {
clntSocks[i] = clntSocks[i+1];
}
break;
}
}
clntCnt--;
ReleaseMutex(hMutex);
closesocket(hClntSock);

return 0;
}

// send to all
void SendMsg(char* msg, int len) {
int i;
WaitForSingleObject(hMutex, INFINITE);
for (i = 0; i < clntCnt; ++i) {
send(clntSocks[i], msg, len, 0);
}
ReleaseMutex(hMutex);
}

客户端代码示例:

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
// chat_client_win.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <winsock2.h>
#include <windows.h>
#include <process.h>

#define BUF_SIZE 100
#define NAME_SIZE 20

unsigned WINAPI SendMsg(void* arg);
unsigned WINAPI RecvMsg(void* arg);
void ErrorHandling(char* message);

char name[NAME_SIZE] = "[DEFAULT]";
char msg[BUF_SIZE];

int main(int argc, char** argv) {
WSADATA wsaData;
SOCKET hSocket;
SOCKADDR_IN servAddr;

HANDLE hSendThread, hRecvThread;

if (argc != 4) {
printf("Usage: %s <ip> <port> <name>\n", argv[0]);
exit(1);
}

if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error!");

sprintf(name, "[%s]", argv[3]);
hSocket = socket(PF_INET, SOCK_STREAM, 0);
if (hSocket == INVALID_SOCKET)
ErrorHandling("socket() error!");

memset(&servAddr, 0, sizeof(servAddr));
servAddr.sin_family = AF_INET;
// Windows 无 inet_aton,替换为 inet_addr(功能一致)
servAddr.sin_addr.s_addr = inet_addr(argv[1]);
servAddr.sin_port = htons(atoi(argv[2]));

if (connect(hSocket, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR)
ErrorHandling("connect() error!");

hSendThread = (HANDLE)_beginthreadex(NULL, 0, SendMsg, (void*)&hSocket, 0,NULL);
hRecvThread = (HANDLE)_beginthreadex(NULL, 0, RecvMsg, (void*)&hSocket, 0,NULL);

WaitForSingleObject(hSendThread, INFINITE);
WaitForSingleObject(hRecvThread, INFINITE);

closesocket(hSocket);
WSACleanup();
return 0;
}

void ErrorHandling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

// send thread main
unsigned WINAPI SendMsg(void* arg) {
SOCKET hSock = *((SOCKET*)arg);
char nameMsg[NAME_SIZE + BUF_SIZE];
while (1) {
fgets(msg, BUF_SIZE, stdin);
if (!strcmp(msg, "q\n") || !strcmp(msg, "Q\n")) {
closesocket(hSock);
exit(0);
}
sprintf(nameMsg, "%s %s", name, msg);
send(hSock, nameMsg, strlen(nameMsg), 0);
}

return 0;
}

// read thread main
unsigned WINAPI RecvMsg(void* arg) {
int hSock = *((SOCKET*)arg);
char nameMsg[NAME_SIZE + BUF_SIZE];
int strLen;

while(1) {
strLen = recv(hSock, nameMsg, NAME_SIZE + BUF_SIZE - 1, 0);
if (strLen == -1) {
return -1;
}
nameMsg[strLen] = 0;
fputs(nameMsg, stdout);
}

return 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
28
29
30
31
# 终端1
.\chat_serv_win.exe 9527
servaddr size: 16
Connected client IP: 127.0.0.1
Connected client IP: 127.0.0.1

# 终端2
.\chat_client_win.exe 127.0.0.1 9527 gmq
hello
[gmq] hello
[cola] 干嘛?
奇变偶不变
[gmq] 奇变偶不变
[cola] 符号看象限

[gmq] 鸡
[cola] 你太美
q

# 终端3
.\chat_client_win.exe 127.0.0.1 9527 cola
[gmq] hello
干嘛?
[cola] 干嘛?
[gmq] 奇变偶不变
符号看象限
[cola] 符号看象限
[gmq] 鸡
你太美
[cola] 你太美
q

移出临界区中耗时操作

习题中有个问题,在基于信号量同步的方式中,Read退出临界区的时间较长,怎么改进?

1
2
3
4
5
6
7
8
9
10
unsigned WINAPI Read(void* arg) {
int i;
for (i = 0; i < 5; ++i) {
fputs("Input num: ", stdout);
WaitForSingleObject(semTwo, INFINITE); // -1
scanf("%d", &num);
ReleaseSemaphore(semOne, 1, NULL); // +1
}
return 0;
}

思路很简单,把scanf改变共享变量num的操作分开,先给一个temp输入值,再赋值,这样就不会因为scanf在临界区卡半天了。

1
2
3
4
5
6
7
8
9
10
11
12
13
unsigned WINAPI Read(void* arg) {
int i;
int temp; // 临时变量
for (i = 0; i < 5; ++i) {
fputs("Input num: ", stdout);
scanf("%d", &temp); // 耗时操作,无锁

WaitForSingleObject(semTwo, INFINITE); // 进入临界区
num = temp; // 仅赋值需要保护
ReleaseSemaphore(semOne, 1, NULL); // 通知其他线程
}
return 0;
}

Chapter21 异步通知I/O模型

请注意,我们本章学习的事异步通知I/O模型。我们常说的异步I/O,就是send/recv不阻塞,是真正的异步;而异步通知,是内核通过事件对象主动通知“有事件发生”,但程序仍需要阻塞等待事件对象,等待期间线程挂起。

重点在”通知”二字上,回顾之前学习的selectselect 模型被称为 同步 I/O 模型,原因有二:

  1. 事件检测是同步的:你主动调用 select,内核检查 socket 状态,然后返回结果。整个过程是 “你调用,内核回应” 的同步交互,不是内核主动通知。
  2. 数据拷贝是同步的select 返回后,你需要自己调用 recv/send 来拷贝数据。这些调用也是同步阻塞的(即使数据已就绪,拷贝过程仍由用户线程执行)。

因此,“同步”包含两个层面

  • 通知同步:程序需要主动调用函数来获取 I/O 状态(轮询或一次性检查,本章学习的是异步通知)。
  • I/O 同步:程序自己执行读写操作,且可能阻塞。

Linux 下的 epoll 虽然事件通知效率高,但数据拷贝仍需用户调用 recv/send,所以仍属于 同步 I/O(但常被称为“异步通知”,指事件通知方式)。

Windows 上的异步通知与异步 I/O

1. 异步通知(WSAEventSelect / WSAAsyncSelect->和UI相关,本书不研究)

  • 异步通知:内核主动将事件对象设为有信号(或发送窗口消息),程序不再轮询。
  • 但仍然是同步 I/O:因为收到通知后,程序仍需调用 recv/send 来同步拷贝数据。
  • 特点:通知方式异步,I/O 操作同步。

2. 异步 I/O(IOCP - I/O Completion Port)

  • 真正的异步 I/O:你调用 WSARecv 并提供一个缓冲区,函数立即返回。内核完成数据接收并直接拷贝到你的缓冲区后,将完成通知放入完成端口队列。
  • 数据拷贝由内核完成:你的线程不需要执行 recv 来拷贝数据,只需调用 GetQueuedCompletionStatus 取出完成通知,此时数据已经在用户缓冲区中。
  • 特点:I/O 操作本身是异步的(调用不阻塞,内核完成拷贝)。
模型 事件通知方式 数据拷贝者 是否同步 I/O
select 同步(主动调用) 用户调用 recv
WSAEventSelect 异步(内核设置事件) 用户调用 recv 是(因为拷贝是同步的)
IOCP 异步(完成端口) 内核直接拷贝 否(真正的异步 I/O)

关键区别:异步通知 ≠ 异步 I/O。异步 I/O 必须是 内核完成数据拷贝,且读写调用立即返回。

在 Windows 上,实现真正的异步 I/O 需要使用 IOCP(或支持重叠 I/O 的函数,如 ReadFile + 重叠结构)。而 WSAEventSelect 只能提供异步通知,但仍属于同步 I/O 模型。

理解异步事件通知模型

说明:本章节描述的是 Windows 下 异步事件通知 + 同步 I/O 模型(如 WSAEventSelect)。
它比 select 高效:内核主动通过事件对象通知程序,无需每次轮询所有 socket。
但数据拷贝仍需程序主动调用 recv/send,且等待事件时线程会阻塞。
后面章节学习的 真正异步 I/O(IOCP) 才是“内核完成所有拷贝、调用立即返回”的模型。

一句话总结:你告诉内核:“某个 socket 有事件(可读/可写/关闭等)时,请将事件对象设为有信号来通知我”。程序阻塞等待事件对象,收到通知后自己调用 recv 读取数据。

四种模型对比

模型 等待数据 拷贝数据 发起 I/O 调用是否阻塞 线程在事件等待期间能否做其他事
同步阻塞 用户进程 用户进程 是(recv 阻塞) ❌ 不能
select + 超时 内核(但需要用户调用 select) 用户进程 select 阻塞,可超时 ⚠️ 超时后可做,但需循环
WSAEventSelect(当前学习) 内核(主动事件通知) 用户进程(调用 recv 等待事件时阻塞 ❌ 不能(但可一次等待多个事件)
真正异步 I/O(IOCP,后续学习) 内核 内核 否(立即返回) ✅ 可以(完全解放)

流程对比图(增加 WSAEventSelect 流程)

sequenceDiagram
    participant U as 用户程序
    participant K as 内核
    participant D as 设备

    Note over U,D: 同步阻塞
    U->>K: recv()
    K->>D: 等待数据
    D-->>K: 数据到达
    K->>U: 拷贝数据
    U->>U: 处理

    Note over U,D: select + 超时
    loop 循环
        U->>K: select(超时)
        K-->>U: 就绪或超时
        alt 就绪
            U->>K: recv()
            K->>U: 拷贝数据
            U->>U: 处理
        else 超时
            U->>U: 做其他事
        end
    end

    Note over U,D: WSAEventSelect(异步事件通知)
    U->>K: WSAEventSelect(注册事件)
    K-->>U: 立即返回
    U->>K: WSAWaitForMultipleEvents(阻塞)
    K->>D: 等待数据(内核检测事件)
    D-->>K: 数据到达
    K->>U: 事件对象有信号
    U->>K: WSAEnumNetworkEvents + recv()
    K->>U: 拷贝数据
    U->>U: 处理

    Note over U,D: 真正异步I/O(IOCP)
    U->>K: WSARecv(缓冲区)
    K-->>U: 立即返回
    U->>U: 做其他事(不等待)
    K->>D: 等待数据
    D-->>K: 数据到达
    K->>U: 内核直接拷贝到用户缓冲区
    K->>U: 完成端口通知
    U->>U: 直接使用数据

核心差异总结

维度 同步阻塞 select + 超时 WSAEventSelect(当前学习) 真正异步 I/O(后续)
发起调用是否阻塞 select 阻塞(可超时) WSAWaitForMultipleEvents 阻塞 否(立即返回)
数据谁拷贝 用户 用户 用户(recv 时拷贝) 内核
CPU 利用率 低(等待时空转) 中(超时可做别的事) 中(等待时阻塞,但事件通知减少遍历) 高(完全解放)
编程复杂度 简单 中等 中等偏复杂 复杂(回调/完成端口)
典型场景 简单脚本 中等并发、非阻塞 Windows 下中等并发、事件驱动 高并发 I/O 密集型服务器

理解和实现异步通知I/O模型

两种方法:WSAEventSelect 函数和 WSAAsyncSelect 函数,后者是UI相关内容,只需要知道有这么个函数就行。

WSAEventSelect 函数

监听套接字发生的事件,事件对象初始是手动且无信号的,监听到对应事件后,事件对象变为有信号。

1
2
3
4
5
6
7
#include <winsock2.h>

int WSAEventSelect(
SOCKET s, // 套接字
WSAEVENT hEventObject, // 事件对象句柄
long lNetworkEvents // 感兴趣的网络事件掩码
);

参数说明

参数 类型 含义
s SOCKET 要关联的套接字
hEventObject WSAEVENT WSACreateEvent() 创建的事件对象句柄
lNetworkEvents long 位掩码,指定要监听的事件类型(见下表)

返回值

  • 成功:返回 0
  • 失败:返回 SOCKET_ERROR(可调用 WSAGetLastError() 获取错误码)

第三个参数 lNetworkEvents 可取的事件类型(位掩码)

事件标志 含义 触发条件
FD_READ 可读 有数据可读(包括带外数据)
FD_WRITE 可写 连接建立后通常立即触发;发送缓冲区空出空间时触发
FD_ACCEPT 可接受连接 监听套接字上有新连接请求
FD_CONNECT 连接完成 connect() 异步完成时(非阻塞连接)
FD_CLOSE 连接关闭 对方关闭连接(优雅关闭)
FD_OOB 带外数据 收到带外数据
FD_QOS QoS 变化 服务质量变化
FD_GROUP_QOS 组QoS变化 保留
FD_ROUTING_INTERFACE_CHANGE 路由变化 保留
FD_ADDRESS_LIST_CHANGE 地址列表变化 保留

常用组合:监听套接字监听 FD_ACCEPT;客户端套接字监听 FD_READ | FD_WRITE | FD_CLOSE

作用机制

  1. 关联:将套接字 s 与事件对象 hEventObject 关联,并指定内核需要监测的网络事件。
  2. 状态变化:当套接字上发生任何一个 lNetworkEvents 中指定的事件时,内核会将该事件对象设置为有信号(signaled)
  3. 等待:用户线程可以调用 WSAWaitForMultipleEvents() 等待该事件对象(或多个事件对象)。
  4. 获取详情:事件对象有信号后,调用 WSAEnumNetworkEvents() 查询具体发生了哪个事件(因为一个事件对象可能对应多个事件类型),同时该函数会自动重置事件对象为无信号,以便下一次使用。

核心优势:内核不再需要像 select 那样每次传入 fd_set 并遍历,而是通过事件对象主动通知,减少了用户/内核态切换和内存复制开销。

配套辅助函数

  • WSAEVENT WSACreateEvent(void); 创建手动重置事件,初始无信号(比前面CreateEvent方便很多)。
  • BOOL WSACloseEvent(WSAEVENT hEvent); 关闭事件句柄。
  • DWORD WSAWaitForMultipleEvents(...); 等待一个或多个事件对象。
  • int WSAEnumNetworkEvents(SOCKET s, WSAEVENT hEventObject, LPWSANETWORKEVENTS lpNetworkEvents); 获取具体事件并自动重置事件。
WSAEventSelect 配套辅助函数详解

这四个函数共同构成了 Winsock 事件通知模型的基础,配合 WSAEventSelect 实现异步 I/O。

1. WSACreateEvent
1
2
<winsock2.h>
WSAEVENT WSACreateEvent(void);

返回值

  • 成功:返回一个事件对象句柄(WSAEVENT,本质是 HANDLE)。
  • 失败:返回 WSA_INVALID_EVENT(即 NULL),可调用 WSAGetLastError() 获取错误码。

说明

  • 创建 手动重置(manual-reset)初始无信号(nonsignaled) 的事件对象。
  • 专门用于 Winsock 的事件通知模型,与 WSAEventSelect 配合使用。
  • 创建的事件对象不能用于 WaitForSingleObject 以外的其他等待函数吗?实际上可以,WSAEVENT 就是 HANDLE,可用于任何等待函数(如 WaitForSingleObjectWaitForMultipleObjects),但推荐使用 WSAWaitForMultipleEvents(它内部处理了 Alertable 等 Winsock 特殊需求)。

注意

  • 不需要时调用 WSACloseEvent 关闭。
  • 每个 socket 通常对应一个独立的事件对象(也可以多个 socket 共享一个事件,但不推荐,因为无法区分具体哪个 socket 就绪)。
2. WSACloseEvent
1
2
3
<winsock2.h>
// 其中 WSAEVENT 是 #difine WSAEVENT HANDLE
BOOL WSACloseEvent(WSAEVENT hEvent);

参数

  • hEvent:要关闭的事件对象句柄。

返回值

  • 成功:非零(TRUE
  • 失败:FALSE,可调用 WSAGetLastError() 获取错误码。

说明

  • 关闭由 WSACreateEvent 创建的事件对象,释放内核资源。
  • 关闭后该句柄不可再用。

使用时机

  • 当套接字关闭时,应先 WSAEventSelect(sock, NULL, 0) 解除关联(避免残留),再关闭事件对象。
  • 或者直接关闭套接字并关闭事件对象(但解除关联更安全)。
3. WSAWaitForMultipleEvents

等待一个或多个事件对象变为有信号。返回满足条件的索引或超时,是事件通知模型的核心等待函数。

1
2
3
4
5
6
7
8
<winsock2.h>
DWORD WSAWaitForMultipleEvents(
DWORD cEvents, // 事件对象个数(1 ~ WSA_MAXIMUM_WAIT_EVENTS,通常 64)
const WSAEVENT *lphEvents, // 事件对象数组
BOOL fWaitAll, // 等待模式:TRUE=所有事件有信号,FALSE=任一事件有信号
DWORD dwTimeout, // 超时(毫秒),WSA_INFINITE 表示无限等待
BOOL fAlertable // 是否可被 APC(Asynchronous Procedure Call,异步过程调用) 唤醒(通常 FALSE)
);

返回值

  • 成功且 fWaitAll == FALSE:返回 WSA_WAIT_EVENT_0 + 索引(满足条件的最小索引)。
  • 成功且 fWaitAll == TRUE:返回 WSA_WAIT_EVENT_0(所有事件都有信号)。
  • 超时:返回 WSA_WAIT_TIMEOUT
  • 失败:返回 WSA_WAIT_FAILED,可调用 WSAGetLastError()

参数详解

  • cEvents:事件数组中的元素个数,不能为 0,不能超过 WSA_MAXIMUM_WAIT_EVENTS(64)。
  • lphEvents:指向事件句柄数组的指针。
  • fWaitAll
    • TRUE:等待所有事件都变为有信号才返回。实际中很少用,因为不同 socket 的事件独立。
    • FALSE:任何一个事件有信号即返回,最常用。
  • dwTimeout:毫秒数,WSA_INFINITE(-1)表示无限等待。
  • fAlertable
    • TRUE:允许 APC(Asynchronous Procedure Call,异步过程调用)唤醒等待,用于可等待的 I/O 等高级场景。
    • FALSE:通常设为 FALSE,忽略 APC。

注意

  • 该函数等待的是事件对象,不是套接字。事件对象由 WSAEventSelect 与套接字关联,当套接字上发生感兴趣的事件时,事件对象变为有信号。
  • 返回索引后,需要根据索引找到对应的套接字,然后调用 WSAEnumNetworkEvents 获取具体事件类型。
  • WaitForMultipleObjects 类似,但专门为 Winsock 优化,处理了 WSA_WAIT_EVENT_0 等返回值的定义。

限制

  • 最大等待对象数 64。如果需要处理更多套接字,需分组管理或使用 IOCP。
4. WSAEnumNetworkEvents

区分事件类型。 查询套接字上发生的具体网络事件(如 FD_READ、FD_CLOSE),并自动将关联的事件对象重置为无信号。

1
2
3
4
5
6
<winsock2.h>
int WSAEnumNetworkEvents(
SOCKET s, // 套接字
WSAEVENT hEventObject, // 关联的事件对象(可为 NULL)
LPWSANETWORKEVENTS lpNetworkEvents // 传出参数,接收事件信息
);

参数

  • s:要查询的套接字。
  • hEventObject:与该套接字关联的事件对象句柄(由 WSAEventSelect 设置)。如果提供,该函数会自动将事件对象重置为无信号。如果不需要重置,可以传 NULL
  • lpNetworkEvents:指向 WSANETWORKEVENTS 结构体的指针,用于接收发生的事件及错误码。

返回值

  • 成功:0
  • 失败:SOCKET_ERROR,可调用 WSAGetLastError()

WSANETWORKEVENTS 结构体

1
2
3
4
typedef struct _WSANETWORKEVENTS {
long lNetworkEvents; // 发生的事件掩码(如 FD_READ | FD_CLOSE)
int iErrorCode[FD_MAX_EVENTS]; // 每个事件对应的错误码数组
} WSANETWORKEVENTS;
  • lNetworkEvents:位掩码,表示当前发生的所有事件。可以检查 if (netEvents.lNetworkEvents & FD_READ)
  • iErrorCode:索引使用 FD_READ_BITFD_WRITE_BIT 等宏(定义在 winsock2.h 中),例如 netEvents.iErrorCode[FD_READ_BIT] 如果是 0 表示该事件无错误,否则是相应错误码(如 WSAECONNRESET)。

“如果发生 FD_XXX 相关错误,则在 iErrorCode[FD_XXX_BIT] 中保存除0以外的其他值”

说明

  • 必须在 WSAWaitForMultipleEvents 返回后,对每个有信号的事件对象对应的套接字调用此函数。
  • 该函数会查询内核中该套接字上已发生的网络事件(一次调用可获取所有待处理事件,不会丢失)。
  • 如果传入了 hEventObject,函数会自动将事件对象重置为无信号,所以之后可以再次等待该事件对象。
  • 如果没有传入事件对象(NULL),则不会重置事件对象,需要手动重置(但通常都传入)。

典型用法

1
2
3
4
5
6
7
8
9
10
11
WSANETWORKEVENTS events;
if (WSAEnumNetworkEvents(socket, hEvent, &events) == 0) {
if (events.lNetworkEvents & FD_READ) {
if (events.iErrorCode[FD_READ_BIT] == 0) {
// 可以安全调用 recv
}
}
if (events.lNetworkEvents & FD_CLOSE) {
// 处理关闭
}
}

注意

  • 调用 WSAEnumNetworkEvents 后,事件对象被重置,但如果有新的事件在调用之后立即发生,事件对象会再次被内核设为有信号,不会丢失。
  • 该函数只返回自上次调用之后发生的事件(或关联后尚未处理的事件)。多次调用之间,内核会累积事件,直到被枚举出来。
工作流程总结
  1. 初始化WSAStartup
  2. 创建事件event = WSACreateEvent()
  3. 关联WSAEventSelect(socket, event, FD_READ | FD_CLOSE)
  4. 收集事件句柄:将所有 socket 的事件句柄放入数组
  5. 等待index = WSAWaitForMultipleEvents(count, events, FALSE, INFINITE, FALSE)
  6. 获取详情WSAEnumNetworkEvents(socket, event, &netEvents)
  7. 处理事件:根据 netEvents.lNetworkEvents 分支处理
  8. 重复:回到步骤 5(事件已自动重置,可再次等待)
  9. 清理:对每个 socket 调用 WSAEventSelect(socket, NULL, 0)(可选),WSACloseEvent(event)closesocket(socket),最后 WSACleanup

简单示例片段(演示配套函数)

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
WSAEVENT events[MAX_SOCKETS];
SOCKET sockets[MAX_SOCKETS];
int eventCount = 0;

// 假设已经创建监听 socket 并关联事件
sockets[0] = listenSock;
events[0] = WSACreateEvent();
WSAEventSelect(listenSock, events[0], FD_ACCEPT);
eventCount = 1;

while (1) {
DWORD idx = WSAWaitForMultipleEvents(eventCount, events, FALSE, INFINITE, FALSE);
if (idx == WSA_WAIT_FAILED) break;
int i = idx - WSA_WAIT_EVENT_0;
SOCKET s = sockets[i];
WSANETWORKEVENTS netEvents;
if (WSAEnumNetworkEvents(s, events[i], &netEvents) == 0) {
if (s == listenSock && (netEvents.lNetworkEvents & FD_ACCEPT)) {
// accept 新连接...
} else if (netEvents.lNetworkEvents & FD_READ) {
// recv 数据...
} else if (netEvents.lNetworkEvents & FD_CLOSE) {
// 关闭连接并清理事件...
}
}
}

异步I/O通知模型实现回声服务器端

客户端使用5.2 Windows echo客户端实现

下面是服务端代码:

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
// AsyncNotifyEchoServ_win.c
#include <stdio.h>
#include <string.h>
#include <winsock2.h>

#define BUF_SIZE 100

void CompressSockets(SOCKET hSocketArr[], int idx, int total);
void CompressEvents(WSAEVENT hEventArr[], int idx, int total);
void ErrorHandling(char* message);

int main(int argc, char* argv[]) {
WSADATA wsaData;
SOCKET hServSock, hClntSock;
SOCKADDR_IN servAddr, clntAddr;

SOCKET hSockArr[WSA_MAXIMUM_WAIT_EVENTS];
WSAEVENT hEventArr[WSA_MAXIMUM_WAIT_EVENTS];
WSAEVENT newEvent;
WSANETWORKEVENTS netEvents;

int numOfClntSock = 0;
int strLen, i;
int posInfo, startIdx;
int szClntAddr;
char msg[BUF_SIZE];

if (argc != 2) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}

if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error!");

hServSock = socket(PF_INET, SOCK_STREAM, 0);
if (hServSock == INVALID_SOCKET)
ErrorHandling("socket() error!");

memset(&servAddr, 0, sizeof(servAddr));
printf("servaddr size: %zu\n", sizeof(servAddr));
servAddr.sin_family = AF_INET;
servAddr.sin_addr.s_addr = htonl(INADDR_ANY);
servAddr.sin_port = htons(atoi(argv[1]));

if (bind(hServSock, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR)
ErrorHandling("bind() error!");

if (listen(hServSock, 5) == SOCKET_ERROR)
ErrorHandling("listen() error!");

// WSAEventSelect注册异步通知事件(echo服务端注册接受连接事件即可,有新连接来到时将会触发)
// 内核在事件发生时自动把事件置为signaled
newEvent = WSACreateEvent();
if ( WSAEventSelect(hServSock, newEvent, FD_ACCEPT) == SOCKET_ERROR)
ErrorHandling("WSAEventSelect() error!");

// 事件对象和套接字是配套的
hSockArr[numOfClntSock] = hServSock;
hEventArr[numOfClntSock] = newEvent;
numOfClntSock++;

while (1) {
// 阻塞等待事件发生(第三个参数WaitForAll为FALSE,等到第一个就返回)
posInfo = WSAWaitForMultipleEvents(numOfClntSock, hEventArr, FALSE, WSA_INFINITE, FALSE);
startIdx = posInfo - WSA_WAIT_EVENT_0;

for (i = startIdx; i < numOfClntSock; ++i) {
// 上面检测到第一个事件发生返回了,后面的还是需要检测,于是遍历检测进行后续处理
int sigEventidx = WSAWaitForMultipleEvents(1, &hEventArr[i], TRUE, 0, FALSE);
if (sigEventidx == WSA_WAIT_FAILED || sigEventidx == WSA_WAIT_TIMEOUT) {
continue;
}
else {
sigEventidx = i;
// 获取具体事件类型,把事件置为non-signaled
WSAEnumNetworkEvents(hSockArr[sigEventidx], hEventArr[sigEventidx], &netEvents);

// 服务端接受连接事件发生 -> 请求连时处理(添加并监听客户端)
if (netEvents.lNetworkEvents & FD_ACCEPT) {
if (netEvents.iErrorCode[FD_ACCEPT_BIT] != 0) {
puts("Accept Error!");
break;
}
szClntAddr = sizeof(clntAddr);
hClntSock = accept(hSockArr[sigEventidx], (SOCKADDR*)&clntAddr, &szClntAddr);
if (hClntSock == INVALID_SOCKET)
ErrorHandling("accept() error!");
// 使用异步事件对象监听新的客户端(对客户端监测读和关闭事件)
newEvent = WSACreateEvent();
WSAEventSelect(hClntSock, newEvent, FD_READ|FD_CLOSE);

hSockArr[numOfClntSock] = hClntSock;
hEventArr[numOfClntSock] = newEvent;
numOfClntSock++;
puts("connected new client...");
}

// 接收数据时处理
if (netEvents.lNetworkEvents & FD_READ) {
if (netEvents.iErrorCode[FD_READ_BIT] != 0) {
puts("Read Error");
break;
}

strLen = recv(hSockArr[sigEventidx], msg, sizeof(msg), 0);
send(hSockArr[sigEventidx], msg, strLen, 0);
}

// 断开连接时处理
if (netEvents.lNetworkEvents & FD_CLOSE) {
if (netEvents.iErrorCode[FD_CLOSE_BIT] != 0) {
puts("Close Error!");
break;
}
WSACloseEvent(hEventArr[sigEventidx]);
closesocket(hSockArr[sigEventidx]);

numOfClntSock--;
CompressSockets(hSockArr, sigEventidx, numOfClntSock);
CompressEvents(hEventArr, sigEventidx, numOfClntSock);
}
}
}
}

WSACleanup();

return 0;
}

void ErrorHandling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

void CompressSockets(SOCKET hSocketArr[], int idx, int total) {
int i;
for (i = idx; i < total; ++i)
hSocketArr[i] = hSocketArr[i+1];
}

void CompressEvents(WSAEVENT hEventArr[], int idx, int total) {
int i;
for (i = idx; i < total; ++i)
hEventArr[i] = hEventArr[i+1];
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 终端1
.\AsyncNotifyEchoServ_win.exe 9527
servaddr size: 16
connected new client...

# 终端2
.\client_echo2_win.exe 127.0.0.1 9527
Connected........
Input message(Q to quit): hello
Message from server: hello
Input message(Q to quit): 鸡你太美
Message from server: 鸡你太美
Input message(Q to quit): q

Chapter22 重叠I/O模型

理解I/O重叠模型

应用程序发起 I/O(WSARecv/WSASend)后立即返回,内核后台完成数据等待和拷贝,完成后通过事件完成例程通知程序。OVERLAPPED 结构是每次 I/O 操作的“身份证”,用于关联请求和结果。

核心流程

sequenceDiagram
    participant App as 应用程序
    participant K as 内核
    participant D as 网卡/设备

    App->>K: WSARecv(提供缓冲区 & OVERLAPPED)
    K-->>App: 立即返回(WSA_IO_PENDING)
    App->>App: 处理其他任务
    K->>D: 等待数据
    D-->>K: 数据到达
    K->>App: 内核直接拷贝到用户缓冲区
    K->>App: 设置 OVERLAPPED::hEvent 有信号
    App->>K: GetOverlappedResult 获取结果
    App->>App: 使用数据

重叠 I/O 的三种通知方式

方式 实现 适用场景
事件对象 OVERLAPPED.hEvent + WSAWaitForMultipleEvents 少量 I/O(≤64),简单
完成例程(APC) 回调函数 + 可警告等待(如 SleepEx 少量 I/O,想避免事件管理
I/O 完成端口(IOCP) CreateIoCompletionPort + GetQueuedCompletionStatus 大量并发(数千连接),高性能服务器首选

与传统同步 recv 对比

flowchart LR
    subgraph 同步recv
        A1[调用 recv] --> A2[阻塞等待数据] --> A3[内核拷贝数据] --> A4[继续执行]
    end

    subgraph 重叠I/O
        B1[调用 WSARecv] --> B2[立即返回] --> B3[处理其他任务]
        B2 -.-> C1[内核等待/拷贝]
        C1 --> C2[完成通知]
    end

学习建议

现在的位置 下一步目标
理解重叠 I/O 的投递WSARecv 立即返回) 理解重叠 I/O 的完成(事件/APC 通知)
理解 OVERLAPPED 结构与缓冲区的关联 可以跳过事件通知,直接学习 IOCP(更简洁高效)
掌握 WSA_IO_PENDING 的含义 掌握 GetQueuedCompletionStatus 和完成包处理

总结

重叠 I/O 把“等待数据”和“拷贝数据”交给内核完成,程序发起后解放 CPU。
它是 IOCP 的基础——IOCP 只是重叠 I/O 的一种“完成通知”方式。

重叠I/O的I/O完成确认

重叠 I/O 的实践,核心是掌握“投递”和“完成”这两个阶段。

I/O完成确认,有两种方式,关键分别在于WSASend/WSARecv的第六和第七个参数,我们一个个来(后面的示例代码可以看到)。

核心结构体

这两个结构体是重叠I/O操作的基石。

  • WSAOVERLAPPED:每个异步I/O操作的“身份证”。

    • 作用:关联一个I/O请求和它的完成状态。你发起请求时传给它,操作完成后,系统通过它来通知你。
    • 关键成员hEvent。如果使用事件通知,你需要创建事件对象并将句柄赋给它。
  • WSABUF:描述数据缓冲区的“容器”。

    • 作用:用于WSARecv/WSASend等函数,指定数据从哪里读、写到哪里去。
    • 关键成员len(缓冲区长度)和 buf(指向数据缓冲区的指针)。

关键 API 和工作流

这些函数是实现重叠I/O操作的主要工具。

  • WSASocket():创建支持重叠I/O的套接字。
    • 关键:必须指定 WSA_FLAG_OVERLAPPED 标志。这是使用重叠I/O的前提。
  • WSARecv() / WSASend():发起异步接收/发送操作。
    • 作用:调用后立即返回,不阻塞线程。内核会在后台完成数据的收发。
    • 关键参数
      • lpBuffers:指向 WSABUF 结构体的指针,指定数据缓冲区。
      • lpOverlapped:指向 WSAOVERLAPPED 结构体的指针。
      • 关键返回值WSA_IO_PENDING(可通过WSAGetLastError()获取错误信息),表示I/O操作已成功投递,正在后台进行。
  • WSAWaitForMultipleEvents():等待一个或多个事件对象变为有信号状态。
    • 作用:当使用事件通知方式时,用此函数阻塞等待,直到I/O操作完成,事件对象被触发。
  • WSAGetOverlappedResult():获取一个已完成的异步I/O操作的结果。
    • 作用:在收到完成通知后调用,获取本次传输的实际字节数等结果。
    • 注意:此函数与IOCP的 GetQueuedCompletionStatus 作用类似,但使用场景不同。
sequenceDiagram
    participant App as 应用程序
    participant OS as 操作系统内核

    App->>App: 1. WSASocket(WSA_FLAG_OVERLAPPED)
    App->>App: 2. 准备 WSABUF 和 WSAOVERLAPPED

    App->>OS: 3. WSARecv/WSASend (投递请求)
    OS-->>App: 返回 SUCCESS / WSA_IO_PENDING

    Note over App,OS: 应用程序可执行其他任务

    OS->>App: 4. I/O 完成,触发通知 (事件/APC/IOCP)
    App->>OS: 5. WSAGetOverlappedResult (获取结果)
    OS-->>App: 返回传输字节数等

服务端示例(使用overlapped结构)

使用事件对象验证I/O完成与否。

客户端使用5.2 Windows echo客户端实现

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
// OverlappedEchoServ_win.c
// 单线程、异步、支持多客户端的 Echo 服务器(重叠 I/O + 事件通知)
// 使用 select 非阻塞检查监听 socket,主循环同时处理完成事件和新连接

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <winsock2.h>

#define BUF_SIZE 100
#define MAX_CLIENTS 64

void ErrorHandling(char* message);

// 每个客户端的状态
typedef struct _CLIENT {
SOCKET sock;
WSAOVERLAPPED overlapped;
WSAEVENT event;
char buf[BUF_SIZE];
WSABUF dataBuf;
DWORD recvBytes;
DWORD flags;
BOOL valid;
} CLIENT;

CLIENT clients[MAX_CLIENTS];
WSAEVENT eventArray[MAX_CLIENTS];
int clientCount = 0;

// 添加新客户端,投递第一个 WSARecv
void AddClient(SOCKET sock) {
if (clientCount >= MAX_CLIENTS) {
printf("Max clients reached, reject.\n");
closesocket(sock);
return;
}

CLIENT* pClient = &clients[clientCount];
pClient->sock = sock;
pClient->event = WSACreateEvent(); // manual reset event
if (pClient->event == WSA_INVALID_EVENT) {
ErrorHandling("WSACreateEvent() error!");
closesocket(sock);
return;
}

memset(&pClient->overlapped, 0, sizeof(WSAOVERLAPPED));
pClient->overlapped.hEvent = pClient->event;
pClient->dataBuf.buf = pClient->buf;
pClient->dataBuf.len = BUF_SIZE;
pClient->recvBytes = 0;
pClient->flags = 0;
pClient->valid = TRUE;

// 投递异步接收
// 确认重叠I/O完成的两种方法:WSARecv 第六个参数,基于事件对象;第七个参数,基于Completion Routine
// (这里我们使用第六个参数,基于事件对象确认重叠I/O完成)
int ret = WSARecv(sock, &pClient->dataBuf, 1, &pClient->recvBytes,
&pClient->flags, &pClient->overlapped, NULL);
if (ret == SOCKET_ERROR) {
int err = WSAGetLastError();
if (err != WSA_IO_PENDING) {
printf("WSARecv() failed for new client, error %d\n", err);
closesocket(sock);
WSACloseEvent(pClient->event);
pClient->valid = FALSE;
return;
}
}
// 成功投递,加入事件数组
eventArray[clientCount] = pClient->event;
clientCount++;
printf("New client %d connected, total clients: %d\n", sock, clientCount);
}

int main(int argc, char* argv[]) {
WSADATA wsaData;
SOCKET hServSock;
SOCKADDR_IN servAddr;
int ret;

if (argc != 2) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}

if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error!");

// 创建重叠 I/O 监听 socket
hServSock = WSASocket(AF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);
if (hServSock == INVALID_SOCKET)
ErrorHandling("WSASocket() error!");

memset(&servAddr, 0, sizeof(servAddr));
servAddr.sin_family = AF_INET;
servAddr.sin_addr.s_addr = htonl(INADDR_ANY);
servAddr.sin_port = htons(atoi(argv[1]));

if (bind(hServSock, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR)
ErrorHandling("bind() error!");

if (listen(hServSock, 5) == SOCKET_ERROR)
ErrorHandling("listen() error!");

printf("Echo server started on port %s\n", argv[1]);

// 主循环
while (1) {
// 处理客户端的 I/O 完成事件
if (clientCount > 0) {
// 非阻塞检查所有客户端事件(非阻塞)
DWORD idx = WSAWaitForMultipleEvents(clientCount, eventArray, FALSE, 0, FALSE);
if (idx != WSA_WAIT_TIMEOUT && idx != WSA_WAIT_FAILED) {
// 有事件触发,遍历所有事件,处理已触发的
for (int i = 0; i < clientCount; i++) {
// 检查单个事件是否触发(非阻塞)
DWORD ret = WSAWaitForMultipleEvents(1, &eventArray[i], FALSE, 0, FALSE);
if (ret != WSA_WAIT_EVENT_0)
continue; // 未触发

CLIENT* pClient = &clients[i];
if (!pClient->valid) continue;

// 获取结果
DWORD transferred;
DWORD flags = 0;
BOOL success = WSAGetOverlappedResult(pClient->sock, &pClient->overlapped,
&transferred, FALSE, &flags);
// 先重置事件(无论成功与否,事件都应该重置,否则下次还会被触发)
WSAResetEvent(pClient->event); // 事件被内核使用,重置需要手动

if (!success || transferred == 0) {
// 客户端断开或出错
printf("Client %d disconnected\n", pClient->sock);
closesocket(pClient->sock);
WSACloseEvent(pClient->event);
pClient->valid = FALSE;
// 从数组中移除(用最后一个覆盖当前)
eventArray[i] = eventArray[clientCount - 1];
clients[i] = clients[clientCount - 1];
clientCount--;
i--; // 重检当前位置
continue;
}

// 成功收到数据
printf("Received %d bytes from %d: %.*s\n", transferred, pClient->sock,
transferred, pClient->buf);
// 回显(同步发送)
send(pClient->sock, pClient->buf, transferred, 0);

// 重新投递 WSARecv
memset(&pClient->overlapped, 0, sizeof(WSAOVERLAPPED));
pClient->overlapped.hEvent = pClient->event;
pClient->dataBuf.buf = pClient->buf;
pClient->dataBuf.len = BUF_SIZE;
pClient->recvBytes = 0;
pClient->flags = 0;
ret = WSARecv(pClient->sock, &pClient->dataBuf, 1,
&pClient->recvBytes, &pClient->flags,
&pClient->overlapped, NULL);
// printf("ret: %d\n", ret);
if (ret == SOCKET_ERROR) {
int err = WSAGetLastError();
// 是pending说明传输暂未完成,不是的话就说明连接断开或者传输错误
if (err != WSA_IO_PENDING) {
printf("WSARecv() failed for %d, error %d\n", pClient->sock, err);
closesocket(pClient->sock);
WSACloseEvent(pClient->event);
pClient->valid = FALSE;
eventArray[i] = eventArray[clientCount - 1];
clients[i] = clients[clientCount - 1];
clientCount--;
i--;
}
}
// 如果 ret == 0 表示数据已经立即就绪,但这种情况极少,我们仍然会等事件触发,所以没问题。
}
}
}

// 接受新连接(非阻塞)
fd_set readSet;
FD_ZERO(&readSet);
FD_SET(hServSock, &readSet);
struct timeval tv = {0, 0}; // 立即返回
if (select(0, &readSet, NULL, NULL, &tv) > 0) {
SOCKADDR_IN clntAddr;
int addrLen = sizeof(clntAddr);
SOCKET hClntSock = accept(hServSock, (SOCKADDR*)&clntAddr, &addrLen);
if (hClntSock != INVALID_SOCKET) {
AddClient(hClntSock);
}
}

// 避免忙等,稍微休息一下
Sleep(1);
}

// 清理(不会执行)
WSACleanup();
return 0;
}

void ErrorHandling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(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
# 终端1
.\OverlappedEchoServ_win.exe 9527
Echo server started on port 9527
New client 240 connected, total clients: 1
Received 6 bytes from 240: hello

New client 248 connected, total clients: 2
Received 3 bytes from 248: hi

Received 7 bytes from 240: 鸡你

Received 7 bytes from 248: 太美

Client 248 disconnected
Client 240 disconnected

# 终端2
.\client_echo2_win.exe 127.0.0.1 9527
Connected........
Input message(Q to quit): hello
Message from server: hello
Input message(Q to quit): 鸡你
Message from server: 鸡你
Input message(Q to quit): q

# 终端3
.\client_echo2_win.exe 127.0.0.1 9527
Connected........
Input message(Q to quit): hi
Message from server: hi
Input message(Q to quit): 太美
Message from server: 太美
Input message(Q to quit): q

服务端示例(使用Completion Routine函数)

  • APC 是 Windows 内核提供的一种机制:系统可以在某个线程处于“可警告等待”状态时,插入一个回调函数(APC 例程)在该线程的上下文中执行
  • 当调用 WSARecv 并传入 CompletionRoutine(第七个参数)时,内核会在 I/O 完成后生成一个 APC,并将其放入该线程的 APC 队列中。

Windows 提供了一批 Ex 结尾 或明确支持 fAlertable 参数的等待函数(最后一个参数):

函数 如何进入 Alertable
SleepEx(dwMilliseconds, bAlertable) bAlertable = TRUE
WaitForSingleObjectEx(hHandle, dwMilliseconds, bAlertable) bAlertable = TRUE
WaitForMultipleObjectsEx(nCount, lpHandles, bWaitAll, dwMilliseconds, bAlertable) bAlertable = TRUE
WSAWaitForMultipleEvents(cEvents, lphEvents, fWaitAll, dwTimeout, fAlertable) fAlertable = TRUE
SignalObjectAndWait(hObjectToSignal, hObjectToWaitOn, dwMilliseconds, bAlertable) bAlertable = TRUE
MsgWaitForMultipleObjectsEx(带 MWMO_ALERTABLE 标志) 通过标志

注意:常规的 WaitForSingleObject(不带 Ex)和 Sleep(不带 Ex不支持 Alertable,它们不会执行 APC 队列。

工作原理流程图

sequenceDiagram
    participant App as 用户线程
    participant Kernel as 内核
    participant IO as I/O 完成

    App->>Kernel: WSARecv(..., CompletionRoutine)
    Kernel-->>App: 返回 WSA_IO_PENDING(I/O 已投递)

    App->>Kernel: 调用 SleepEx(100, TRUE) 或
WSAWaitForMultipleEvents(..., fAlertable=TRUE) Note over App,Kernel: 线程进入可警告等待状态 IO-->>Kernel: I/O 操作完成 Kernel->>App: 插入 APC(包含 CompletionRoutine)到该线程队列 Note over Kernel: 由于线程处于 Alertable,内核立即
调度 APC 执行 Kernel->>App: 执行 CompletionRoutine 回调 App->>App: 在回调中处理数据、重新投递 WSARecv App-->>Kernel: 回调返回 Note over App,Kernel: 线程继续等待(若超时未到)
或 SleepEx 返回(若已超时)

与事件模型的本质区别

模型 如何让线程等待 APC 如何执行
事件通知 WSAWaitForMultipleEvents 等待事件对象(非 Alertable) 需要手动循环检测事件,再用 WSAGetOverlappedResult 取结果
APC 回调 调用带 ExAlertable 的等待函数 内核自动在 Alertable Wait 时执行回调,无需手动轮询

客户端使用5.2 Windows echo客户端实现

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
// OverlappedEchoServ_APC_win.c
// 重叠 I/O + Completion Routine(APC 回调)
// 单线程、异步、支持多客户端 Echo 服务器
// 使用 Completion Routine 通知 I/O 完成

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <winsock2.h>

#define BUF_SIZE 100
#define MAX_CLIENTS 64

void ErrorHandling(char* message);

// 每个客户端的状态
typedef struct _CLIENT {
SOCKET sock;
WSAOVERLAPPED overlapped;
char buf[BUF_SIZE];
WSABUF dataBuf;
DWORD recvBytes;
DWORD flags;
BOOL valid;
} CLIENT;

CLIENT clients[MAX_CLIENTS];
int clientCount = 0;

// 声明完成例程(必须在 WSARecv 调用之前声明)
void CALLBACK CompletionRoutine(DWORD dwError, DWORD cbTransferred,
LPWSAOVERLAPPED lpOverlapped, DWORD dwFlags);

// 添加新客户端,投递第一个 WSARecv
void AddClient(SOCKET sock) {
if (clientCount >= MAX_CLIENTS) {
printf("Max clients reached, reject.\n");
closesocket(sock);
return;
}

CLIENT* pClient = &clients[clientCount];
pClient->sock = sock;
pClient->recvBytes = 0;
pClient->flags = 0;
pClient->valid = TRUE;

memset(&pClient->overlapped, 0, sizeof(WSAOVERLAPPED));
// 注意:使用 Completion Routine 时,OVERLAPPED::hEvent 必须为 NULL
pClient->overlapped.hEvent = NULL;

pClient->dataBuf.buf = pClient->buf;
pClient->dataBuf.len = BUF_SIZE;

// 投递异步接收,第七个参数传入完成例程
int ret = WSARecv(sock, &pClient->dataBuf, 1, &pClient->recvBytes,
&pClient->flags, &pClient->overlapped,
CompletionRoutine); // <--- 关键:传入回调函数

if (ret == SOCKET_ERROR) {
int err = WSAGetLastError();
if (err != WSA_IO_PENDING) {
printf("WSARecv() failed for new client, error %d\n", err);
closesocket(sock);
pClient->valid = FALSE;
return;
}
}

clientCount++;
printf("New client %d connected, total clients: %d\n", sock, clientCount);
}

// 完成例程(APC 回调)
// 当 I/O 完成时,系统会在线程处于可警告等待状态时调用此函数
void CALLBACK CompletionRoutine(DWORD dwError, DWORD cbTransferred,
LPWSAOVERLAPPED lpOverlapped, DWORD dwFlags) {
// 根据 OVERLAPPED 地址找到对应的客户端
// 这里我们通过遍历 clients 数组来匹配(实际项目可优化为从 OVERLAPPED 反推)
CLIENT* pClient = NULL;
for (int i = 0; i < clientCount; i++) {
if (&clients[i].overlapped == lpOverlapped && clients[i].valid) {
pClient = &clients[i];
break;
}
}
if (pClient == NULL) return;

// 处理错误或连接关闭
if (dwError != 0 || cbTransferred == 0) {
printf("Client %d disconnected (error: %d, transferred: %d)\n",
pClient->sock, dwError, cbTransferred);
closesocket(pClient->sock);
pClient->valid = FALSE;
// 注意:这里不能直接修改 clientCount 或移动数组,因为 APC 在循环中调用
// 我们通过 valid 标记惰性删除,主循环中会清理
return;
}

// 成功收到数据,直接在此处理(无需主循环介入)
printf("Received %d bytes from %d: %.*s\n", cbTransferred, pClient->sock,
cbTransferred, pClient->buf);

// 回显(同步发送)
send(pClient->sock, pClient->buf, cbTransferred, 0);

// 重新投递下一个 WSARecv(在回调中直接发起下一次投递)
memset(&pClient->overlapped, 0, sizeof(WSAOVERLAPPED));
pClient->overlapped.hEvent = NULL;
pClient->dataBuf.buf = pClient->buf;
pClient->dataBuf.len = BUF_SIZE;
pClient->recvBytes = 0;
pClient->flags = 0;

int ret = WSARecv(pClient->sock, &pClient->dataBuf, 1,
&pClient->recvBytes, &pClient->flags,
&pClient->overlapped, CompletionRoutine);

if (ret == SOCKET_ERROR) {
int err = WSAGetLastError();
if (err != WSA_IO_PENDING) {
printf("WSARecv() failed for %d, error %d\n", pClient->sock, err);
closesocket(pClient->sock);
pClient->valid = FALSE;
}
}
}

// 清理无效客户端(由主循环定期调用)
void CleanupClients() {
for (int i = 0; i < clientCount; i++) {
if (!clients[i].valid) {
// 用最后一个覆盖当前
clients[i] = clients[clientCount - 1];
clientCount--;
i--; // 重新检查当前位置
}
}
}

int main(int argc, char* argv[]) {
WSADATA wsaData;
SOCKET hServSock;
SOCKADDR_IN servAddr;

if (argc != 2) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}

if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error!");

// 创建重叠 I/O 监听 socket
hServSock = WSASocket(AF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);
if (hServSock == INVALID_SOCKET)
ErrorHandling("WSASocket() error!");

memset(&servAddr, 0, sizeof(servAddr));
servAddr.sin_family = AF_INET;
servAddr.sin_addr.s_addr = htonl(INADDR_ANY);
servAddr.sin_port = htons(atoi(argv[1]));

if (bind(hServSock, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR)
ErrorHandling("bind() error!");

if (listen(hServSock, 5) == SOCKET_ERROR)
ErrorHandling("listen() error!");

printf("Echo server (APC completion routine) started on port %s\n", argv[1]);

// 主循环:使用可警告等待 + 非阻塞 accept
while (1) {
// 1. 清理无效客户端(惰性删除)
CleanupClients();

// 2. 接受新连接(非阻塞)
fd_set readSet;
FD_ZERO(&readSet);
FD_SET(hServSock, &readSet);
struct timeval tv = {0, 0};
if (select(0, &readSet, NULL, NULL, &tv) > 0) {
SOCKADDR_IN clntAddr;
int addrLen = sizeof(clntAddr);
SOCKET hClntSock = accept(hServSock, (SOCKADDR*)&clntAddr, &addrLen);
if (hClntSock != INVALID_SOCKET) {
AddClient(hClntSock);
}
}

// 3. 进入可警告等待状态,让 APC 回调有机会执行
// SleepEx(0, TRUE) 不会阻塞,但会立即检查 APC 队列并执行
// 如果有 APC 等待,执行回调;如果没有,立即返回。
// 我们用一个短超时,既能让 APC 执行,又不会让 CPU 空转太久。
SleepEx(100, TRUE); // 等待 100ms,TRUE 表示可被 APC 唤醒
}

WSACleanup();
return 0;
}

void ErrorHandling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}

同步I/O与异步I/O

graph TD
    subgraph 同步I/O [同步 I/O 阵营(自己搬数据)]
        A[阻塞 I/O] -->|特点| A1[recv 阻塞]
        B[非阻塞 I/O] -->|特点| B1[recv 立即返回
需轮询或 select] C[异步通知
WSAEventSelect] -->|特点| C1[内核通知可读
但仍需自己调 recv 搬数据] end subgraph 异步I/O [异步 I/O 阵营(内核搬数据)] D[重叠 I/O
Overlapped I/O] -->|具体实现| E[事件通知
Event] D -->|具体实现| F[完成例程
APC] D -->|具体实现| G[完成端口
IOCP] style D fill:#e1f5fe style G fill:#c8e6c9 end B -.->|不是异步| D C -.->|不是异步| D

Chapter23 IOCP

性能最好的Windows平台I/O模型,也最难理解。

IOCP(Input Output Completion Port,输入输出完成端口)。

通过重叠I/O理解IOCP

和书上不同,我们之前就实现了重叠I/O的服务端,但是没有把socket设置为非阻塞模式(但程序本身是非阻塞的),通过ioctlsocket可以把socket设置为非阻塞模式,这样,通过accept创建出来的socket也是非阻塞的,并且,在没有连接请求的时候调用了accept就会返回错误值INVALID_SOCKET,我们可以通过这个值去处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int mode = 1;
ioctlsocket(hServSock, FIONBIO, &mode);
...
while (1) {
...
hRecvSock = accept(hServSock, ...);
if (hRecvSock == INVALID_SOCKET) {
if (WSAGetLastError() == WSAEWOULDBLOCK) {
continue;
} else {
// 非阻塞的错误处理
ErrorHandling("accept error().");
}
}
...
}

不管怎样,我们都要分析重叠I/O服务端的缺点,才能引出本章主题。

缺点主要在重复调用非阻塞模式的accept函数,或者是为了进入alertable wait状态调用的SleepEx等函数,会很影响性能。

这是重叠I/O固有缺陷,为此我们做以下改进:

让main线程调用accept函数,再单独创建一个线程负责客户端I/O

这就是IOCP模型,意思就是,IOCP会创建专用的I/O线程,此线程负责与所有客户端I/O。

IOCP的内容不是只是创建个专用线程处理I/O,而且,这不是重点,重点是:

  • I/O是否以非阻塞模式工作?
  • 如何确定非阻塞模式的I/O是否完成?

分阶段实现IOCP程序

套接字完成I/O之后,需要请求把状态信息注册到指定的CP对象(Completioon Port对象),为此,我们需要做以下两项工作:

  • 创建完成端口对象;
  • 建立完成端口对象和套接字之间的联系。
创建端口对象和建立关联

为此,我们需要先了解一下 CreateIoCompletionPort 函数:

1
2
3
4
5
6
7
8
9
#include <windows.h>

HANDLE CreateIoCompletionPort(
HANDLE FileHandle, // 要关联的句柄(socket转换为HANDLE)
HANDLE ExistingCompletionPort, // 已有的完成端口句柄(创建时传NULL)
ULONG_PTR CompletionKey, // 用户自定义数据(通常传上下文结构体指针)
DWORD NumberOfConcurrentThreads // 并发线程数(0表示自动 = CPU核心数,若想获得最佳性能,基本只能实验得到,不同机器环境有不同影响因素)
);
// 返回值:成功返回完成端口句柄,失败返回NULL

示例用法:

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
#include <winsock2.h>
#include <windows.h>

// ---------- 1. 创建完成端口 ----------
HANDLE hIOCP = CreateIoCompletionPort(
INVALID_HANDLE_VALUE, // 创建端口时不关联任何句柄
NULL, // 创建新端口
0, // 创建时无意义
0 // 0 = 自动使用CPU核心数
);

// ---------- 2. 关联监听socket ----------
SOCKET listenSock = WSASocket(AF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);
// ... bind, listen ...

CreateIoCompletionPort(
(HANDLE)listenSock, // 要关联的socket
hIOCP, // 已存在的完成端口
(ULONG_PTR)listenSock, // 完成键(这里传socket本身,实际常传上下文指针)
0 // 关联时此参数被忽略
);

// ---------- 3. 关联客户端socket(accept后) ----------
SOCKET clientSock = accept(listenSock, ...);

// 自定义上下文结构体
typedef struct {
SOCKET sock;
char buf[1024];
// ... 其他字段
} PER_SOCKET_CONTEXT;

PER_SOCKET_CONTEXT* pCtx = malloc(sizeof(PER_SOCKET_CONTEXT));
pCtx->sock = clientSock;

CreateIoCompletionPort(
(HANDLE)clientSock,
hIOCP,
(ULONG_PTR)pCtx, // 完成键传上下文指针,工作者线程可直接使用
0
);

// ---------- 4. 关闭 ----------
CloseHandle(hIOCP);
确认完成端口已完成的I/O和现成的I/O处理
1
2
3
4
5
6
7
8
9
10
11
#include <windows.h>

BOOL GetQueuedCompletionStatus(
HANDLE CompletionPort, // IOCP句柄(CreateIoCompletionPort的返回值)
LPDWORD lpNumberOfBytesTransferred, // [输出] 实际传输的字节数
PULONG_PTR lpCompletionKey, // [输出] 关联socket时传入的完成键(通常是上下文指针)
LPOVERLAPPED *lpOverlapped, // [输出] 投递I/O时使用的OVERLAPPED结构地址
DWORD dwMilliseconds // 超时时间(毫秒),INFINITE表示无限等待
);
// 返回值:非零表示成功取到完成包;零表示失败(需调用GetLastError()分析原因)
// 关键:该函数会阻塞调用线程,直到完成端口队列中出现完成包、超时或出错

示例用法:

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
// ============================================================
// 典型用法(工作者线程主循环 + 错误处理)
// ============================================================
#include <winsock2.h>
#include <windows.h>

// 假设上下文结构已定义
typedef struct {
SOCKET sock;
char buf[1024];
// ... 其他字段
} PER_SOCKET_CONTEXT;

typedef struct {
WSAOVERLAPPED overlapped;
WSABUF dataBuf;
char buffer[1024];
} PER_IO_CONTEXT;

DWORD WINAPI WorkerThread(LPVOID lpParam) {
HANDLE hIOCP = (HANDLE)lpParam;
DWORD bytesTransferred;
ULONG_PTR completionKey;
LPOVERLAPPED lpOverlapped;

while (1) {
// 1. 从完成端口取包(阻塞等待)
BOOL success = GetQueuedCompletionStatus(
hIOCP,
&bytesTransferred,
&completionKey,
&lpOverlapped,
INFINITE
);

// 2. 错误 / 断开处理
if (!success) {
// 如果 lpOverlapped 为 NULL,通常是端口关闭或严重错误
if (lpOverlapped == NULL) {
// 退出线程或执行清理(PostQueuedCompletionStatus发来的退出信号)
break;
}
// lpOverlapped 不为 NULL 但失败,说明该连接出错/断开
PER_IO_CONTEXT* pIo = CONTAINING_RECORD(lpOverlapped, PER_IO_CONTEXT, overlapped);
closesocket(pIo->sock);
free(pIo);
continue;
}

// 3. 成功取到完成包,还原上下文
PER_SOCKET_CONTEXT* pSocket = (PER_SOCKET_CONTEXT*)completionKey;
PER_IO_CONTEXT* pIo = CONTAINING_RECORD(lpOverlapped, PER_IO_CONTEXT, overlapped);

// 4. 客户端主动断开(接收到0字节)
if (bytesTransferred == 0) {
closesocket(pSocket->sock);
free(pIo);
free(pSocket);
continue;
}

// 5. 正常处理数据(如Echo回显)
printf("Received %d bytes from %d\n", bytesTransferred, pSocket->sock);
send(pSocket->sock, pIo->buffer, bytesTransferred, 0);

// 6. 重新投递下一个WSARecv(必须重新投递才能收到后续数据)
memset(&pIo->overlapped, 0, sizeof(WSAOVERLAPPED));
pIo->dataBuf.buf = pIo->buffer;
pIo->dataBuf.len = 1024;
WSARecv(pSocket->sock, &pIo->dataBuf, 1, NULL, NULL, &pIo->overlapped, NULL);
}
return 0;
}

基于IOCP的Echo服务器端

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
// IOCPEchoServ_win_fixed.c
// IOCP Echo 服务器(deepseek 改进版)

#include <stdio.h>
#include <stdlib.h>
#include <process.h>
#include <winsock2.h>
#include <windows.h>

#define BUF_SIZE 1024
#define READ 3
#define WRITE 5
#define EXIT 9 // 新增:退出标志

typedef struct {
SOCKET hClntSock;
SOCKADDR_IN clntAddr;
} PER_HANDLE_DATA, *LPPER_HANDLE_DATA;

typedef struct {
OVERLAPPED overlapped;
WSABUF wsaBuf;
char buffer[BUF_SIZE];
int rwMode;
LPPER_HANDLE_DATA handleInfo; // 反向关联,方便清理
} PER_IO_DATA, *LPPER_IO_DATA;

unsigned int WINAPI EchoThreadMain(LPVOID CompletionPortIO);
void ErrorHandling(char* message);

int main(int argc, char* argv[]) {
WSADATA wsaData;
HANDLE hComPort;
SYSTEM_INFO sysInfo;
LPPER_HANDLE_DATA handleInfo;
LPPER_IO_DATA ioInfo;
SOCKET hServSock;
SOCKADDR_IN servAddr;
int recvBytes = 0, flags = 0;
int i;

if (argc != 2) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}

if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error!");

// 1. 创建完成端口
hComPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
if (hComPort == NULL)
ErrorHandling("CreateIoCompletionPort() error!");

// 2. 创建工作者线程(CPU核心数 × 2)
GetSystemInfo(&sysInfo);
int threadCount = sysInfo.dwNumberOfProcessors * 2;
printf("Creating %d worker threads...\n", threadCount);

for (i = 0; i < threadCount; ++i) {
HANDLE hThread = (HANDLE)_beginthreadex(NULL, 0, EchoThreadMain, (LPVOID)hComPort, 0, NULL);
if (hThread == 0)
ErrorHandling("_beginthreadex() error!");
CloseHandle(hThread); // 线程运行后,主线程不再需要这个句柄
}

// 3. 创建重叠 I/O 监听 socket(OVERLAPPED)
hServSock = WSASocket(AF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);
if (hServSock == INVALID_SOCKET)
ErrorHandling("WSASocket() error!");

memset(&servAddr, 0, sizeof(servAddr));
servAddr.sin_family = AF_INET;
servAddr.sin_addr.s_addr = htonl(INADDR_ANY);
servAddr.sin_port = htons(atoi(argv[1]));

if (bind(hServSock, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR)
ErrorHandling("bind() error!");

if (listen(hServSock, SOMAXCONN) == SOCKET_ERROR)
ErrorHandling("listen() error!");

printf("Echo server started on port %s\n", argv[1]);
printf("Worker threads: %d\n", threadCount);

// 4. 主循环:接受连接并投递第一个 WSARecv
while (1) {
SOCKET hClntSock;
SOCKADDR_IN clntAddr;
int addrLen = sizeof(clntAddr);

hClntSock = accept(hServSock, (SOCKADDR*)&clntAddr, &addrLen);
if (hClntSock == INVALID_SOCKET) {
if (WSAGetLastError() == WSAEWOULDBLOCK) {
continue; // 非阻塞模式下没连接,继续循环
}
ErrorHandling("accept() error!");
}

printf("Client connected: %d\n", hClntSock);

// 分配连接级上下文
handleInfo = (LPPER_HANDLE_DATA)malloc(sizeof(PER_HANDLE_DATA));
if (handleInfo == NULL) {
closesocket(hClntSock);
continue;
}
handleInfo->hClntSock = hClntSock;
memcpy(&handleInfo->clntAddr, &clntAddr, sizeof(clntAddr));

// 关联 socket 到完成端口
if (CreateIoCompletionPort((HANDLE)hClntSock, hComPort, (ULONG_PTR)handleInfo, 0) == NULL) {
closesocket(hClntSock);
free(handleInfo);
ErrorHandling("CreateIoCompletionPort(associate) error!");
continue;
}

// 分配 I/O 级上下文
ioInfo = (LPPER_IO_DATA)malloc(sizeof(PER_IO_DATA));
if (ioInfo == NULL) {
closesocket(hClntSock);
free(handleInfo);
continue;
}
memset(ioInfo, 0, sizeof(PER_IO_DATA));
ioInfo->wsaBuf.len = BUF_SIZE;
ioInfo->wsaBuf.buf = ioInfo->buffer;
ioInfo->rwMode = READ;
ioInfo->handleInfo = handleInfo; // 反向关联

// 投递第一个 WSARecv
flags = 0;
int ret = WSARecv(hClntSock, &ioInfo->wsaBuf, 1, (LPDWORD)&recvBytes,
(LPDWORD)&flags, &ioInfo->overlapped, NULL);
if (ret == SOCKET_ERROR) {
int err = WSAGetLastError();
if (err != WSA_IO_PENDING) {
// 投递失败,释放资源
printf("WSARecv() failed for %d, error %d\n", hClntSock, err);
closesocket(hClntSock);
free(handleInfo);
free(ioInfo);
}
}
// 若返回 0 或 WSA_IO_PENDING,都是正常情况
}

// 清理(实际不会执行到,因为上面无限循环)
WSACleanup();
return 0;
}

// 工作者线程主函数
unsigned int WINAPI EchoThreadMain(LPVOID CompletionPortIO) {
HANDLE hComPort = (HANDLE)CompletionPortIO;
DWORD bytesTrans;
LPPER_HANDLE_DATA handleInfo;
LPPER_IO_DATA ioInfo;
DWORD flags = 0;
int ret;

while (1) {
// 1. 从完成端口取完成包
BOOL success = GetQueuedCompletionStatus(
hComPort,
&bytesTrans,
(PULONG_PTR)&handleInfo,
(LPOVERLAPPED*)&ioInfo,
INFINITE
);

if (!success) {
// 错误处理:可能是连接断开或端口关闭
if (ioInfo == NULL) {
// 没有关联的 I/O 上下文,可能是 PostQueuedCompletionStatus 发来的退出信号
printf("Worker thread exiting...\n");
break;
}
// 该连接出错
printf("I/O error on socket %d\n", handleInfo->hClntSock);
closesocket(handleInfo->hClntSock);
free(handleInfo);
free(ioInfo);
continue;
}

// 2. 处理完成包
if (ioInfo->rwMode == READ) {
// ----- READ 完成 -----
if (bytesTrans == 0) {
// 客户端正常断开
printf("Client %d disconnected\n", handleInfo->hClntSock);
closesocket(handleInfo->hClntSock);
free(handleInfo);
free(ioInfo);
continue;
}

printf("Received %d bytes from %d\n", bytesTrans, handleInfo->hClntSock);

// 准备发送回显(复用同一个 ioInfo)
memset(&ioInfo->overlapped, 0, sizeof(OVERLAPPED));
ioInfo->wsaBuf.len = bytesTrans; // 发送长度 = 实际接收长度
ioInfo->rwMode = WRITE;

ret = WSASend(handleInfo->hClntSock, &ioInfo->wsaBuf, 1,
NULL, 0, &ioInfo->overlapped, NULL);
if (ret == SOCKET_ERROR) {
int err = WSAGetLastError();
if (err != WSA_IO_PENDING) {
// 投递失败,释放资源
printf("WSASend() failed for %d, error %d\n", handleInfo->hClntSock, err);
closesocket(handleInfo->hClntSock);
free(handleInfo);
free(ioInfo);
}
}
// 发送已投递,完成时会再次进入这个线程的 GetQueuedCompletionStatus
}
else if (ioInfo->rwMode == WRITE) {
// ----- WRITE 完成 -----
// 发送完成,释放发送用的 ioInfo
free(ioInfo);

// 分配新的 ioInfo 用于下一次接收
LPPER_IO_DATA newIoInfo = (LPPER_IO_DATA)malloc(sizeof(PER_IO_DATA));
if (newIoInfo == NULL) {
// 内存不足,断开连接
printf("Memory allocation failed, closing %d\n", handleInfo->hClntSock);
closesocket(handleInfo->hClntSock);
free(handleInfo);
continue;
}
memset(newIoInfo, 0, sizeof(PER_IO_DATA));
newIoInfo->wsaBuf.len = BUF_SIZE;
newIoInfo->wsaBuf.buf = newIoInfo->buffer;
newIoInfo->rwMode = READ;
newIoInfo->handleInfo = handleInfo;

flags = 0;
ret = WSARecv(handleInfo->hClntSock, &newIoInfo->wsaBuf, 1,
NULL, &flags, &newIoInfo->overlapped, NULL);
if (ret == SOCKET_ERROR) {
int err = WSAGetLastError();
if (err != WSA_IO_PENDING) {
printf("WSARecv() failed for %d, error %d\n", handleInfo->hClntSock, err);
closesocket(handleInfo->hClntSock);
free(handleInfo);
free(newIoInfo);
}
}
}
else {
// 未知模式
free(ioInfo);
}
}
return 0;
}

void ErrorHandling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(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
# 终端1
.\IOCPEchoServ_win.exe 9527
Creating 40 worker threads...
Echo server started on port 9527
Worker threads: 40
Client connected: 232
Client connected: 236
Received 6 bytes from 232
Received 7 bytes from 236
Received 7 bytes from 232
Client 232 disconnected
Client 236 disconnected

# 终端2
.\client_echo2_win.exe 127.0.0.1 9527
Connected........
Input message(Q to quit): hello
Message from server: hello
Input message(Q to quit): 苔莓
Message from server: 苔莓
Input message(Q to quit): q

# 终端3
.\client_echo2_win.exe 127.0.0.1 9527
Connected........
Input message(Q to quit): 基尼
Message from server: 基尼
Input message(Q to quit): q

IOCP是重叠I/O的一种完成方式

“重叠”是指 CPU 执行你的业务逻辑代码,与 网卡/DMA控制器 搬运网络数据包,在同一个时间段内并行进行。

graph TD
    subgraph 重叠I/O核心[重叠 I/O 核心(投递机制)]
        A[WSASocket
WSA_FLAG_OVERLAPPED] --> B[WSARecv / WSASend
传入 OVERLAPPED 结构] B --> C[立即返回 WSA_IO_PENDING] C --> D[内核后台处理 I/O
(等待数据 / DMA 拷贝)] end subgraph 完成通知方式[完成通知方式(取结果)] D --> E1[方式1: Event
overlapped.hEvent 有信号] D --> E2[方式2: APC
系统回调 CompletionRoutine] D --> E3[方式3: IOCP
完成包入队列] end E1 --> F1[WSAWaitForMultipleEvents
遍历事件数组] E2 --> F2[SleepEx / 可警告等待
执行回调] E3 --> F3[GetQueuedCompletionStatus
取出完成包] F3 -.->|性能最强| G[支持成千上万连接]

深入理解IOCP

以下内容全是Deepseek生成,辅助理解,但是也请注意是否有错误。

我先用快递柜比喻让你建立直觉,再解释内核机制,最后用流程图展示完整流程。

一、快递柜比喻(IOCP原理)

想象你家楼下有个智能快递柜(IOCP),快递员(网卡/内核)和取件人(你的程序)各司其职:

现实快递柜 IOCP 对应概念
你提前下单买书(发起 I/O) 调用 WSARecv 投递请求
快递员把书直接放进你的专属柜格(用户缓冲区) 硬件 DMA 直接写入用户内存(零拷贝)
快递员在系统里标记“已完成,放入快递柜” 内核将完成包放入 IOCP 队列
你随时有空去刷脸取件(GetQueuedCompletionStatus 工作线程从队列取出完成包处理
你取完件,快递柜格子空出来(重新投递) 处理完数据,重新 WSARecv 准备下一个篮子

重点:你不需要盯着快递员,也不需要自己去仓库搬货。快递员(内核)主动把货放好,你只需要在有空时从快递柜取件即可。

二、内核怎么知道自己要做什么?

IOCP 不是“主动监控”的,而是 “提前下达任务,完成后汇报” 。整个过程是:

阶段 1:投递(你下订单)

1
WSARecv(socket, &ioInfo->wsaBuf, 1, NULL, &flags, &ioInfo->overlapped, NULL);
  • 你调用 WSARecv 时,实际上构建了一个 I/O 请求包(IRP),里面有:
    • 哪个 socket
    • 数据放在哪里(ioInfo->buffer 的物理内存地址)
    • 完成时要通知谁(关联的 IOCP 句柄)
  • 内核将这个 IRP 挂到 socket 所对应的设备对象(网卡驱动)的等待队列上。
  • WSARecv 立即返回(WSA_IO_PENDING),你的线程可以去做其他事。

阶段 2:完成(快递员送货)

  • 当网卡收到数据包时,触发硬件中断。网卡驱动在中断处理中检查:“有没有挂起的 IRP 等待这个 socket 的数据?”
  • 如果有,网卡通过 DMA(直接内存访问) 把数据写入你提供的缓冲区(不需要 CPU 参与拷贝,零拷贝!)。
  • 数据写完后,网卡驱动将 IRP 标记为“完成”,然后把完成信息(socket 指针、传输字节数、OVERLAPPED 指针)放入 IOCP 队列(完成端口是一个内核维护的 FIFO 队列)。

阶段 3:取件(工作线程处理)

  • 工作线程调用 GetQueuedCompletionStatus,从 IOCP 队列取出完成包,拿到数据后直接处理,不需要调用 recv 去拷贝数据
三、为什么 IOCP 性能这么好?
性能瓶颈 传统模型 IOCP 如何优化
数据拷贝 CPU 从内核缓冲区搬到用户缓冲区(消耗 CPU) DMA 直接写入用户缓冲区(零拷贝)
系统调用 select/recv 每次都要进入内核态 WSARecv 投递一次,之后完全在用户态处理数据
线程调度 每个连接一个线程,上下文切换开销大 少量工作线程,通过完成包驱动,无需频繁切换
通知开销 select 每次扫描全部 socket(O(n)) 完成包直接指向具体 socket(O(1))
内存复制 recv 拷贝时用户/内核缓冲区复制 硬件直接写到用户内存,跳过内核缓冲区
四、IOCP 能支持多少客户端?

理论上无上限,只受操作系统资源(内存、端口数)限制。

为什么能做到?

  • 线程数固定:IOCP 只需少量工作线程(如 CPU 核心数 × 2),不随连接数增加。
  • 事件驱动:无论连接 100 个还是 10000 个,内核只在数据完成时发一个完成包。没有数据时,线程在 GetQueuedCompletionStatus 上阻塞,不消耗 CPU。
  • 连接管理:每个连接只需一个 PER_HANDLE_DATA 结构体(几百字节),而线程栈通常要几 MB。10000 个连接用 IOCP 只需几十 MB 内存,而每连接一线程需要几十 GB。
五、完整流程图
sequenceDiagram
    participant App as 用户程序
    participant Kernel as 内核/I/O子系统
    participant NIC as 网卡硬件

    Note over App,NIC: 第1步:投递(下订单)
    App->>Kernel: WSARecv( socket, 用户缓冲区, OVERLAPPED )
    Kernel-->>App: 立即返回 WSA_IO_PENDING
    Kernel->>Kernel: 构建IRP,挂到socket的设备队列
    App->>App: 继续执行其他代码

    Note over App,NIC: 第2步:完成(硬件送货)
    NIC->>NIC: 收到数据包,触发中断
    NIC->>NIC: 检查IRP队列,找到匹配的socket
    NIC->>App: DMA直接写入用户缓冲区(零拷贝)
    NIC->>Kernel: 通知IRP完成
    Kernel->>Kernel: 完成包入IOCP队列

    Note over App,NIC: 第3步:取件(处理数据)
    App->>Kernel: GetQueuedCompletionStatus()
    Kernel-->>App: 返回完成包(含OVERLAPPED指针)
    App->>App: 直接处理用户缓冲区数据
    App->>Kernel: 重新WSARecv投递下一个I/O
六、总结:IOCP 的本质

IOCP = 提前投递 + DMA 零拷贝 + 完成队列通知

  • 内核不需要“主动监控”:它只需要执行你提前下达的 IRP 指令。
  • 高性能来源DMA 零拷贝(绕开 CPU)和减少系统调用(每次 recv 只需投递一次,之后全是用户态处理)。
  • 支持海量连接:因为线程数固定,不随连接数增长,内存开销极小。

理解了这些,你就真正掌握了 IOCP 的灵魂——它让应用程序从“主动等待数据”变成了“被动接收数据就绪的通知”,同时把数据拷贝的工作从 CPU 卸载给了 DMA 硬件。这就是 Windows 下最高性能网络编程的底层秘密。

Part4 结束网络编程

Chapter24 制作http服务器端

我们平时用浏览网页信息时,实际是给背后的web服务器发送了GET请求和POST请求,GET请求用于从web服务器获取信息,POST请求则是上传数据到web服务器(我们只处理GET请求)。

浏览器会在访问页面时创建socket和服务端相连接通信。

需要了解的是,访问一个正儿八经的网页,实际背后是很多GET请求,比如访问B站,一开始只发送一个GET请求:

1
2
GET / HTTP/1.1
Host: www.bilibili.com

服务端收到这个请求后,会返回index.html,但里面引用了大量的资源,于是,浏览器会为服务器开启”多线程/并发”模式,瞬间同时发起数十个新的GET请求,分别索取所需的CSS、JS、图片和字体文件,然后渲染成我们最终看到的网页。

以上内容其实是HTTP协议的一部分,而它还有一个特点,客户端和服务端交换一次数据后就会立刻断开连接,其目的是为了尽可能多地服务客户端,因为浏览网页是个“慢速动作”,我们打开一个网页,一般都会看很久,不会急着点击其中内容去访问,这样,就不必一直连接着占用系统资源了(参考DDos攻击)。

所以,为了完成web服务器(只处理GET请求),我们需要了解和处理的内容有:

  • GET请求发送到服务器的内容,正确解析它;
  • 响应消息的内容,正确回应客户端;
  • 因为一次数据交换就断开连接的特点,IOCP或epoll带来的性能提升并不明显,可以说没有必要使用,用的话还会提升编程复杂度;
  • 使用多线程,为了处理需要并发处理GET请求的情况,客户端每次请求时,都创建一个新的线程响应客户端请求。

在实现之前,我们再了解一下如何访问web服务器,以及web服务器那边如何处理。

一般来说,我们都是访问域名,域名和公网ip绑定,加入有公网IP 180.86.160.223 ,用浏览器访问域名和访问http://180.86.160.223一样,会发送这样一个GET请求(默认80端口):

1
GET / HTTP/1.1

这里的路径是根路径/,服务端必须要处理这个路径,需要返回index.html

1
2
3
4
5
6
7
8
9
10
// 伪代码处理流程
char path[256] = "/index.html"; // 默认值

// 解析浏览器发来的 GET / HTTP/1.1
if (strcmp(requested_path, "/") == 0) {
// 把根路径替换成默认首页文件
sprintf(full_path, "./index.html");
} else {
sprintf(full_path, ".%s", requested_path); // 比如请求 /cat.jpg,打开 ./cat.jpg
}

如果自己指定了端口号,就要在访问时指定端口:

1
http://180.86.160.223:9527

上面是默认访问根目录的情况,我们解析/返回index.html,还有直接访问index.html的情况,浏览器地址访问链接:http://180.86.160.223:9527/index.html,这时候GET请求发来的消息是这样的:

1
2
3
4
5
GET /index.html HTTP/1.1
Host: 180.86.160.223:9527
Connection: keep-alive
User-Agent: Mozilla/5.0 ...
Accept: text/html

不管怎样,我们对//index.html解析后返回index.html就好。

下面,我们可以好好了解消息结构和如何解析了。

请求消息的结构如下(\r\n是换行):

1
2
3
4
5
6
[请求行] \r\n
[头部字段1] \r\n
[头部字段2] \r\n
... \r\n
\r\n <--- 这里是空行,代表头部结束
[请求体] (GET请求没有请求体,所以这里为空)

必须解析的内容只有两处:

  • 请求行(第一行):格式是 方法 路径 版本

    • 我们只需要取中间那个路径(例如 //index.html),这决定了读哪个文件。
  • 空行(\r\n:你必须循环recv直到找到\r\n\r\n,这标志着请求头结束了。因为GET没有请求体,所以读到空行就说明这次接收完成了。

    • 其他所有头部(如HostUser-Agent)对于现在的目标来说,都可以直接丢弃忽略。

接着看看响应消息的结构和如何解析:

1
2
3
4
5
[状态行] \r\n
[头部字段] \r\n
... \r\n
\r\n <--- 这是空行,告诉浏览器头部结束,后面是正文
[响应体] (即 index.html 的文件内容或图片二进制数据)

必须正确构造的字段(三个半):

  • 状态行HTTP/1.1 200 OK(成功)或 HTTP/1.1 404 Not Found(没找到文件)。

  • Content-Type(必填):告诉浏览器这是什么类型的数据。

    • 返回 index.htmlContent-Type: text/html; charset=utf-8
    • 返回 cat.jpgContent-Type: image/jpeg
  • Content-Length(灵魂字段):告诉浏览器这次返回的响应体有多少个字节。

    • 浏览器必须拿到这个数字,去比对收到的数据长度,才能知道文件有没有传完。这个字段不能错。
  • 空行:字符串里必须有一个单独的\r\n,放在头部和文件内容之间。

源码参考代码仓库中thread文件夹下的web_server.c

至此,tcp/ip网络编程的所有内容(书上的)都已经学习完毕。

Chapter25 秃头之路

本章是进阶学习的一些建议,大致就是说网络编程和系统编程关系密切,为学习系统编程,推荐了 《UNIX环境高级编程》《Windows核心编程》 两本书,为进阶网络编程,推荐了 《TCP/IP详解》(卷1~卷3)《TCP/IP协议族》

作者说的,总体意思是需要多学多练多问,但是不管是学,还是练,还是问,都要带入思考,不能看到一个东西不会,就直接搜索答案或者问别人。日积月累,勤学勤思勤练,才能成为一名合格的程序员,这样在专业领域才能有所建树,无论是网络编程,还是图形编程,还是驱动软件…


所有内容就此结束了,没有什么意外应该不会再更新此博客,如果还有网络编程的相关内容想写,我应该会单独开文章写,祝愿大家都能慢慢成长,步履不停,做出自己满意的作品~

酱内~ ξ( ✿>◡❛)
THE END.