代码仓库: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) 是网络数据传输用的软件设备,是由操作系统提供的部件
构建“接电话”套接字 作者形象地将接受连接请求的套接字创建比喻成了接电话,其过程是:
调用socket 函数创建套接字(安装电话机)
调用bind 函数分配ip地址和端口号(分配电话号码)
调用listen 函数转化为可接收请求状态(连接电话线)
调用accept 函数受理连接请求(拿起话筒)
1 2 3 4 5 #include <sys/socket.h> int socket (int domain, int type, int protocol) int bind (int sockfd, struct sockaddr* myaddr, socklen_t addrlen) int listen (int sockfd, int backlog) int accept (int sockfd, struct sockaddr *addr, socklen_t *addrlen)
编写“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 #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) ;
客户端只需要“调用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 #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平台下运行 假设编译出来的可执行文件为hserver和hclient,那么开两个终端 这样运行
先开服务端监听
再运行客户端请求连接
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> int open (const char *path, int flag) ;
打开模式(flag)
含义
O_CREAT
必要时创建文件
O_TRUNC
删除全部现有数据
O_APPEND
维持现有数据,将添加内容保存到其后面
O_RDONLY
只读
O_WRONLY
只写
O_RDWR
读写
可以使用或运算以多种模式打开文件
关闭文件 close函数用于关闭打开的文件,作用对象是文件描述符
1 2 3 4 #include <unistd.h> int close (int fd) ;
将数据写入文件 write函数用于向文件输出(传输)数据,作用对象也是文件描述符
1 2 3 4 5 6 #include <unistd.h> ssize_t write (int fd, const void * buf, size_t nbytes) ;
size_t是unsigned int,ssize_t是signed int,它们是使用typedef定义的类型,不同时代的系统int位数是不一样的,而使用size_t就能很方便进行修改,只需要在typedef处更改即可
读文件中的数据 read函数用于输入(接收)数据,作用对象依然是文件描述符
1 2 3 4 5 6 #include <unistd.h> ssize_t read (int fd, void * buf, size_t nbytes) ;
文件描述符与套接字 作者这里写了个同时创建文件和套接字的程序,然后打印出文件描述符,文件描述符从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> int WSAStartup (WORD wVersionRequested, LPWSADATA lpWSAData) ;
使用MAKEWORD传递版本信息
1 2 MAKEWORD(1 , 2 ); MAKEWORD(2 , 2 );
“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 ) ;
调用该函数之后,Winsock的相关函数便无法再使用,一般在程序将要结束前调用
1.4 基于Windows的套接字相关函数及示例 只是介绍,体会一下和Linux的相似性
基于Windows的套接字相关函数 1 2 3 4 5 6 7 8 9 10 #include <winsock2.h> SOCKET socket (int af, int type, int protocol) ; int bind (SOCKET s, const struct sockaddr* name, int namelen) ; int listen (SOCKET s, int backlog) ; SOCKET accept (SOCKET s, const struct sockaddr* addr, int addrlen) ; int connect (SOCKET s, const struct sockaddr* name, int namelen) ; int closesocket (SOCKET s) ;
在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) ;
通用返回值说明
>0 :成功,返回实际读写的字节数
0 :
recv:对方关闭连接(收到EOF)
send:极少出现0,代表无数据发送
-1 :出错,errno记录错误;非阻塞下EAGAIN/EWOULDBLOCK代表缓冲区暂时不可读/不可写,不算故障
recv 参数解释
int sockfd 已建立连接的TCP客户端套接字文件描述符。
void *buf 接收缓冲区指针,内核会把从网络读到的数据拷贝到这块内存。
size_t len 缓冲区最大可接收字节长度,不能超过buf分配的内存大小。
int flags 控制标志(常用)
0:普通阻塞读写,无特殊行为
MSG_PEEK:窥探数据,数据留在内核缓冲区,下次recv仍能读到
MSG_OOB:读取TCP带外紧急数据
MSG_WAITALL:阻塞直到读满len字节才返回(非阻塞无效)
send 参数解释
int sockfd 已连接TCP套接字fd。
const void *buf 待发送数据缓冲区,const代表不会修改内容。
size_t len 想要发送的数据总字节长度。
int flags 常用标志
0:常规发送
MSG_NOSIGNAL:向关闭的连接发送数据时,不触发SIGPIPE信号
MSG_OOB:发送TCP紧急带外数据
MSG_MORE:本次数据后还有数据,内核暂时不发送,缓存合并发包
理解send&recv
TCP流式特性
send/recv 不保证一次收发全部数据:
send:内核发送缓冲区满时,仅拷贝部分数据,需循环发送剩余内容
recv:对方分次发包,一次只能读到部分数据,需循环读取
阻塞/非阻塞行为差异
阻塞fd:缓冲区无数据(recv)/满(send)时,函数卡死等待
非阻塞fd:无空间/无数据立刻返回-1,errno=EAGAIN
与read/write区别
read/write 没有flags参数;recv/send 多了第四参数,支持网络专属控制选项,网络编程优先使用recv/send。
1 2 3 4 5 6 char buf[1024 ];ssize_t n = recv(clnt_fd, buf, sizeof (buf), 0 );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 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 #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 #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> int send (SOCKET s, const char * buf, int len, int flags) ; int revc (SOCKET s, const char * buf, int len, int flags) ;
windows中套接字句柄需要使用专门的函数传输/接收数据,而linux中文件和套接字都能用相同的函数进行数据I/O,但是linux中也有函数专门操作套接字(同windows有send、recv),这个以后再说
Chapter2 套接字类型与协议设置 这一章是原理篇,会相对比较枯燥,不过很重要,一点点来吧,仅仅关注创建套接字时使用的socket函数就好
2.1 套接字协议及其数据传输特性 关于协议(protocol) 计算机间对话必备的通信规则、完成数据交换而制定的约定
创建套接字 1 2 3 4 5 6 #include <sys/socket.h> int socket (int domain, int type, int protocol) ;
协议族(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; uint16_t sin_port; struct in_addr sin_addr ; char sin_zero[8 ]; }; struct in_addr { in_addr_t s_addr; };
使用自定义类型的好处在于无论在多少位的机器上它们都可以是一样的位数,或根据需要变动位数而无需使用不同类型
结构体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; cahr sa_data[14 ]; };
对比一下sockaddr_in和sockaddr结构体不难发现为什么前者要用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 ; }
字节序(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代表网络字节序;s和l表示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 ) ;
inet_aton的使用频率更高,它将字符串形式IP转换为网络字节序整型IP地址,保存地址使用了in_addr结构体
1 2 3 4 5 #include <arpa/inet.h> int inet_aton (const char * string , struct in_addr* addr) ;
inet_ntoa与上面的作用正好相反,将网络字节序整型IP地址转换为字符串形式IP地址
1 2 3 #include <arpa/inet.h> char * inet_ntoa (struct in_addr adr) ;
注意此函数不要求我们分配空间,所以我们拿到字符串地址之后应该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 = 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> int bind (int sockfd, struct sockaddr* myaddr, socklen_t addrlen) ;
Windows和Linux使用的函数 上述函数均为Linux下的函数,但是windows也可使用,除了inet_addr,它在windows中已被弃用,取而代之的是inet_pton(实际上它就是新函数,以后尽可能用它)
除此之外,windows下还有两个专用函数WSAAddressToString和WSAStringToAddress,它们和inet_ntoa以及inet_addr功能一样,只是了解一下,用它们的话会降低程序的可移植性和兼容性,说这话的意思就是能不用就不用
Chapter4 基于TCP的服务器端/客户端(1) 4.1 理解TCP和UDP TCP是Transmission Control Protocol,传输控制协议,意为对数据传输过程的控制
这篇文章简单讲述了OSI七层网络模型:OSI七层网络模型
不过我们只探讨四层(对编程来讲足矣)
TCP/IP协议栈 数据传输会根据我们选择的协议而走TCP层或UDP层
链路层 计算机间通信通过设备(路由器、交换机等)进行物理连接的标准
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> int listen (int sock, int backlog) ;
sock像是门卫,接待客人(连接请求),如果有正在受理的事物就让他们在等候室等着,backlog是等候室的大小
必须服务端listen之后客户端connect才会产生数据交互
受理客户端请求 listen之后就要准备按序受理请求了,受理请求也是利用套接字进行的,但不是服务器套接字 ,毕竟人家都是门卫了,所以我们要创建一个新的套接字受理请求,用accept便可创建
1 2 3 4 5 6 #include <sys/socket.h> int accept (int sock, struct sockaddr* addr, socklen_t * addrlen) ;
调用accept函数受理连接请求时服务器端进入阻塞(blocking)状态,这时候来的连接请求都会被加入等待队列
这样,连接请求相当于是插头,服务器套接字和accept生成的套接字共同组成一个完整的插座(插座的两个孔),连接请求插到这两部分socket组成的完整插座便可正常通信了(这样比喻也许不太合理,毕竟服务器socket充当的是“门卫”的角色)
TCP客户器端默认的函数调用顺序
发送连接请求是通过connect函数完成的
1 2 3 4 5 #include <sys/socket.h> int connect (int sock, struct sockaddr* servaddr, socklen_t addrlen) ;
客户端调用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 #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 #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 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)); str_len = read(clnt_sock, message, BUF_SIZE-1 ); 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 #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 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)); recv_total_len = 0 ; while (recv_total_len < str_len) { recv_one_len = read(clnt_sock, message + recv_total_len, BUF_SIZE-1 ); 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 #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 ])); serv_addr.sin_addr.s_addr = inet_addr(argv[1 ]); if (serv_addr.sin_addr.s_addr == INADDR_NONE) error_handling("inet_addr() error!" ); 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通信分为三步
建立连接
进行数据交互
断开连接
每一步都由不同的过程进行
建立连接 是通过 三次握手(Three-way Handshark) 进行的。套接字是以全双工(Full-duplx)方式工作的,意思是可以双向传输数据
(1) 建立连接时,首先A向B发送
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> ssizt_t sendto (int socket, void * buff, size_t nbytes, int flags, struct sockaddr* to, socklen_t addrlen) ;
1 2 3 4 5 6 7 8 9 10 #include <sys/socket.h> ssizt_t recvfrom (int socket, void * buff, size_t nbytes, int flags, struct sockaddr* from, socklen_t addrlen) ;
因为 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 #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 #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 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函数传输数据有三个阶段:
向UDP套接字注册目标IP和端口号
传输数据
删除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) ;
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) ;
Chapter7 优雅地断开套接字连接 之前的代码里最后都是直接调用close或closesocket关闭套接字,这种是完全断开连接,无法进行数据IO,就连IO缓冲里面的数据也会销毁,无法接收或传输,这样可能会造成数据丢失,为了避免这种情况,我们可以使用半关闭(Half-close) ,只关闭一部分数据交换中的流,让套接字只能接收数据或只能发送数据
两个套接字建立连接相当于打通管道,数据流相当于水流,只能朝一个方向移动,建立连接后每台主机都拥有两个流,一个输入流,一个输出流,半关闭就是关闭这两个流其中的一个
“流”是套接字建立连接形成的概念,所以只有TCP中存在流,UDP中没有
1 2 3 4 5 #include <sys/socket.h> int shutdown (int sock, int howto) ;
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 #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 ; if (argc < 2 ) { printf ("Usage: %s <port>\n" , argv[0 ]); exit (1 ); } printf ("-------- Start ----\n" ); fp = fopen(filename, "rb" ); if (!fp) error_handling("fopen() error" ); 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 ])); if (inet_aton("127.0.0.1" , &serv_addr.sin_addr) == 0 ) error_handling("inet_aton() error!" ); printf ("HIOST IP: %s\n" , inet_ntoa(serv_addr.sin_addr)); 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!" ); 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 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" ); 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)); } shutdown(serv_sock, SHUT_WR); 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 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 #include <stdio.h> #include <stdlib.h> #include <string.h> #include <winsock2.h> #include <ws2tcpip.h> #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; 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, "127.0.0.1" , &serv_addr.sin_addr.S_un.S_addr); serv_addr.sin_port = htons(9098 ); if ( connect(clnt_sock, (struct sockaddr*)&serv_addr, sizeof (serv_addr)) == SOCKET_ERROR ) error_handling("connect() error!" ); 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(clnt_sock, "FILE_SIZE" , 9 , 0 ); recv(clnt_sock, message, sizeof (message) - 1 , 0 ); printf ("File size: %s" , message); send(clnt_sock, "Thank you!" , 10 , 0 ); 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) ;
返回的结构体存储了域名和IP,所以只利用这个结构体我们就能获得域名和IP的信息
1 2 3 4 5 6 7 8 struct hostent { char *h_name; char **h_aliases; int h_addrtype; int h_length; char **h_addr_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与一个域名绑定,利用多台服务器进行负载均衡
利用下面这段代码测试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 #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 #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) ;
1 2 3 #include <winsock2.h> struct hostent* gethostbyaddr (const char * addr, int len, int type) ;
Chapter9 套接字的多种可选项 套接字有多种特性,这些特性可以通过设置可选项更改(Linux和Windows几乎一样) (注:实际可设置的可选项比下表多得多)
SOL_SOCKET:套接字相关通用设置
IPPROTO_TCP:TCP协议相关
IPPROTO_IP:IP协议相关
get/set 套接字特性 1 2 3 4 5 6 7 8 9 #include <sys/socket.h> int getsockopt (int sock, int level, int optname, void * optval, socklen_t * optlen) ;
1 2 3 4 #include <sys/socket.h> int setsockopt (int sock, int level, int optname, const void * optval, socklen_t optlen) ;
套接字类型只能在创建时决定,之后不可更改 验证套接字类型:协议层为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 #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_SNDBUF 和 SO_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 #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 #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)
先断开连接(先发送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 ; 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) ; int getsockopt (SOCKET s, int level, int optname, char * optval, int * optlen) ;
参数
含义
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 #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 #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 ) { gval += 2 , lval += 2 ; } else { 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 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 #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 父进程(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 不能直接使用,需要用宏解析:
WIFEXITED(status)
作用:判断子进程是否正常终止 (return 退出 / exit 退出)
返回值:正常终止 → 非0;异常终止 → 0
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 #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(); if (pid == 0 ) { return 3 ; } else { printf ("CHILD PID: %d \n" , pid); pid = fork(); 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 ); } } return 0 ; }
示例结果:
1 2 3 4 5 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) ;
核心参数 & 返回值:
pid_t pid
-1:等待任意子进程 退出(和 wait 作用一致)
正数:等待指定PID的子进程退出
*int status :存储子进程的退出状态(和 wait 用法一致)
int options :核心选项
WNOHANG:非阻塞模式 (重点),没有子进程退出时,函数立即返回 0,不阻塞父进程
返回值
成功:返回退出的子进程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 #include <stdio.h> #include <unistd.h> #include <sys/wait.h> int main (int argc, char *argv[]) { int status; pid_t pid = fork(); if (pid == 0 ) { sleep(15 ); return 24 ; } else { 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 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 信号
返回值
作用 :定时触发信号,配合信号处理函数使用。
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 #include <stdio.h> #include <unistd.h> #include <signal.h> void timeout (int sig) { if (sig = SIGALRM) puts ("Time out!" ); alarm(2 ); } 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); 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! 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
返回值
优势 :所有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 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 #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <signal.h> #include <sys/wait.h> 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 Child proc id : 30503 Child proc id : 30504 Hi! I'm child process wait... Hi! I' m child processwait ...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 #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 ); } 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 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点):
套接字(Socket) 是操作系统内核 创建的网络通信资源 ,属于内核对象,存在于系统内核中,进程不能直接操作 套接字本身。
文件描述符 是进程 分配的一个整数编号,本质是进程对内核套接字的引用/句柄 ,进程通过文件描述符来间接使用内核中的套接字。
调用 fork() 创建子进程时,绝对不会复制内核套接字 ,内核中始终只有一个套接字对象;子进程只会复制父进程的文件描述符表 ,同时内核会给这个套接字的引用计数 +1 。
代码中父子进程都要关闭多余文件描述符的原因:子进程不需要监听客户端,所以关闭 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 #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!" ); 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); } } void write_routine (int sock, char *buf) { while (1 ) { fgets(buf, BUF_SIZE, stdin ); if (!strcmp (buf, "q\n" ) || !strcmp (buf, "Q\n" )) { 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 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 :关闭方式,有三个可选值
SHUT_RD:关闭读流 ,无法再接收数据
SHUT_WR:关闭写流 ,无法再发送数据(本节使用)
SHUT_RDWR:同时关闭读/写流,完全断开连接
返回值 :成功返回0,失败返回-1
shutdown的核心特性:它是「流关闭」,不是「文件描述符关闭」,无论套接字的引用计数是多少,都会立即关闭指定的数据流,并向对方 发送EOF 。
现在结合代码和fork的文件描述符复制机制,讲清楚为什么不能用close:
调用fork创建子进程后,父进程和子进程会各持有一份套接字的文件描述符 ,内核中这个套接字的引用计数会变成2 ;
如果我们在子进程退出时调用close(sock):只会关闭子进程的文件描述符,引用计数减为1,父进程的文件描述符依然存在 ,套接字不会被销毁,不会向服务端发送EOF ,服务端的read会一直阻塞等待数据,无法断开连接;
而调用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]:管道写端 ,仅用于向管道中写入数据
返回值
核心特性 :管道是半双工通信 ,数据只能单向流动(写端→读端)。
代码示例:单向管道通信(父<->子 单方向传输)
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 #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 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 #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 ) { write(fds1[1 ], str1, sizeof (str1)); read(fds2[0 ], buf, BUF_SIZE); printf ("Child proc output: %s \n" ,buf); } else { read(fds1[0 ],buf, BUF_SIZE); printf ("Parent proc output: %s \n" ,buf); 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底层原理(自己添加内容)
进程用户空间完全独立(32 位:0~3G;64 位:128T),无法直接互通数据;
所有进程共享内核空间(32 位:3~4G;64 位:128T);
pipe 的本质:操作系统在内核共享空间创建一块缓冲区,作为进程间数据交换的中转站;
数据通信流程:用户内存 → 拷贝到内核缓冲区 → 拷贝到另一个进程用户内存。
说白了就是把数据从一个独立空间复制到共享空间(内核缓冲区),明明读写都是操作同一个内核缓冲区,为啥非要搞两个文件描述符(读 / 写)?用一个文件描述符既能读又能写,难道不行吗?
这是多方设计考量,并非舍近求远。
首先,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 #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 ); } 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!" ); for (i = 0 ; i < 3 ; ++i) { len = read(fds[0 ], msgbuf, BUF_SIZE); fwrite((void *)msgbuf, 1 , len, fp); } fclose(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_set *writefds, fd_set *exceptfds, 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); FD_SET(fd, &fdset); FD_CLR(fd, &fdset); FD_ISSET(fd, &fdset);
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 #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; int result, str_len; char buf[BUF_SIZE]; struct timeval timeout ; FD_ZERO(&reads); FD_SET(0 , &reads); while (1 ) { temps = reads; timeout.tv_sec = 5 ; timeout.tv_usec = 0 ; result = select(1 , &temps, NULL , NULL , &timeout); if (result == -1 ) { puts ("select() error!" ); break ; } else if (result == 0 ) { puts ("Time-out!" ); } else { 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 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 #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; socklen_t adr_sz; int fd_max, str_len, fd_num, i; char buf[BUF_SIZE]; if (argc != 2 ) { printf ("Usage : %s <port>\n" , argv[0 ]); exit (1 ); } 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" ); FD_ZERO(&reads); FD_SET(serv_sock, &reads); fd_max = serv_sock; while (1 ) { cpy_reads = reads; timeout.tv_sec = 5 ; timeout.tv_usec = 0 ; fd_num = select(fd_max + 1 , &cpy_reads, NULL , NULL , &timeout); if (fd_num == -1 ) break ; if (fd_num == 0 ) continue ; for (i = 0 ; i < fd_max + 1 ; i++) { if (FD_ISSET(i, &cpy_reads)) { if (i == serv_sock) { adr_sz = sizeof (clnt_adr); clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz); FD_SET(clnt_sock, &reads); if (fd_max < clnt_sock) fd_max = clnt_sock; printf ("connected client: %d\n" , clnt_sock); } 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); 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 connected client: 4 closed client: 4 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, 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 #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 ); } 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" ); FD_ZERO(&reads); FD_SET(hServSock, &reads); while (1 ) { cpyReads = reads; timeout.tv_sec = 5 ; timeout.tv_usec = 0 ; fdNum = select(0 , &cpyReads, 0 , 0 , &timeout); if (fdNum == SOCKET_ERROR) break ; if (fdNum == 0 ) continue ; for (i = 0 ; i < reads.fd_count; i++) { if (FD_ISSET(reads.fd_array[i], &cpyReads)) { if (reads.fd_array[i] == hServSock) { adrSz = sizeof (clntAdr); hClntSock = accept(hServSock, (SOCKADDR*)&clntAdr, &adrSz); FD_SET(hClntSock, &reads); printf ("connected client: %d\n" , hClntSock); } 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 connected client: 240 closed client: 240 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 的 read、write 传输数据,但这两个函数并非专为网络套接字 设计。 本章聚焦网络编程专属I/O函数 ,补充 Linux 下多样化的数据读写方案,并对比跨平台差异:
首先讲解网络标准收发函数 send / recv ,对比普通 read/write,学习专属参数、操作标志、网络独有特性,理解网络通信与普通文件读写的区别;
接着介绍 readv / writev 分散/聚集 I/O ,支持多块缓冲区一次性读写,减少系统调用次数,解决零散数据拼接、分段传输的效率问题;
最后结合全书惯例,补充 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 表示无特殊特性)
返回值含义:
正整数 :成功传输的实际字节数
0 :仅 recv 会返回,代表对方关闭了连接 (EOF)
-1 :函数调用发生错误
flags 参数可选值是 send/recv 区别于普通读写的关键,支持 5 个常用选项:
标志位
适用函数
含义与作用
MSG_OOB
*
传输带外数据(Out-of-Band) ,发送 / 接收紧急数据
MSG_PEEK
recv
预读取数据 :从缓冲区读取数据,但不删除已读内容 ,下次调用仍能读到相同数据
MSG_DONTROUTE
send
不经过路由表 :直接向本地局域网目标发送数据,仅用于网络调试/特殊场景
MSG_DONTWAIT
*
非阻塞模式 :函数调用时无数据/无法发送,立即返回-1 ,不阻塞等待
MSG_WAITALL
recv
等待全部数据 :直到读取到指定的 nbytes 字节,才会返回,不提前退出
0
*
日常默认写法,没啥特殊动作
补充说明:
标志组合 :多个标志可以用 | 拼接,例如 MSG_DONTWAIT | MSG_PEEK
默认用法 :日常网络通信直接传 0,等同于 read/write
使用场景 :紧急消息、非阻塞通信、数据预读取时,才需要开启特殊标志
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 #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 #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 ); } 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 ; } 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 ./flags_obb_recv 9527 ./flags_obb_send 127.0.0.1 9527 normal: 123456789
虽然这个示例在我的环境测试不生效,但还是要解释一下。
在 Linux/Unix 系统中,当套接字收到带外数据(OOB,即紧急数据)时,内核需要一个目标进程来发送 SIGURG(紧急信号)通知。下面的代码就是告诉内核:“如果这个套接字有紧急数据进来,请把信号发给我(getpid() 返回的进程ID)”。如果不设置,内核不知道该给谁发信号,信号处理函数永远不会被触发。
1 2 3 4 fcntl(recv_sock, F_SETOWN, getpid()); 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 #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 #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 ; } buf[str_len] = 0 ; printf ("Bufferring %d bytes: %s\n" , str_len, buf); 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 ./flags_peek_recv 9527 ./flags_peek_send 127.0.0.1 9527 Bufferring 3 bytes: 123 Read(recv) again. Bufferring 3 bytes: 12
可见发送的数据被读取了两次,其中第一次为预读取,只是读取内容,不清楚缓冲区,第二次读取才清缓冲区。
readv & writev 函数 readv 和 writev 是 分散/聚集 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
结构体数组的元素个数(即要操作的缓冲区数量 )
返回值含义
正整数 :成功读写的总字节数
0:readv 专用,代表读到文件末尾/对方关闭连接
-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 #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 #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
总结就是能用就用,可以减少write和read调用次数,何乐而不为呢?
基于 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 #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 #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 .\oob_recv_win.exe 9527 .\oob_send_win.exe 127.0.0.1 9527 Urgent Msg: 4 12356789 Urgent Msg: 0
感觉这玩意儿比发信号好使多了,倒不如都用select。
然后就是,windows里面没有和writev,readv功能相似的函数,可以通过重叠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 () { int sockfd = socket(AF_INET, SOCK_STREAM, 0 ); FILE* fp = fdopen(sockfd, "r+" ); 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 #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 #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 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(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 ./echo_stdserver 9527 Connected client 1 ./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*。
但是我们还需要进行数据流的半关闭,半关闭的好处:
只关闭单向数据流 ,保留另一方向的通信能力,适配 TCP 请求 - 响应模型(发完数据还能接收回复)。
向对端发送 EOF 结束标志 (只有关闭写流发送,表示”我这边数据发完了”),明确通知对方「数据已发送完毕」,避免对方永久阻塞等待。
比直接 close 更安全可控,不会丢失缓冲区中未传输完成的数据。
适配 I/O 流分离场景,精准关闭读 / 写流,不影响另一独立流的工作。
多进程分离IO流我们可以用 shutdown 对文件描述符的流进行半关闭,但是转换成 FILE* 的只能用fclose,可fclose只需调用一次(即fclose(read或者write FILE*)),套接字便会彻底关闭,发送EOF。
说白了,一个套接字只有一个文件描述符,FILE指针就是个指针,分离读写的俩指针指向同一个文件描述符,我们fclose其中一个就会销毁文件描述符,相当于close(fd),从而使得套接字彻底被关闭,发送EOF。
下面我们就来解决这个问题(并非是简单用 fileno 把 FILE* 转换回fd再shutdown)。
文件描述符的复制和半关闭 为了实现半关闭,我第一反应是是否可以这样:
1 2 3 4 5 6 7 8 9 10 FILE* readfp = fdopen(sock, "r" ); FILE* writefp = fdopen(sock, "w" ); shutdown(fileno(writefp), SHUT_WR); fclose(writefp);
答案是代码能跑,但是资源管理存在风险,工程实用性弱。
理由很简单,一是 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 #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); fclose(writefp); fgets(buf, BUF_SIZE, readfp); fputs (buf, stdout ); fclose(readfp); 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 #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 ./sep_serv2 9527 FROM CLIENT: Thank you! ./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 的三个主要缺点:
每次调用都要把全部文件描述符(fd)集合从用户态拷贝到内核态 ,fd 数量大时开销巨大。
内核检测就绪事件需要遍历所有 fd (O(n) 复杂度)。
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, int op, int 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_ctl(A, EPOLL_CTL_ADD, B, C); 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, 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; 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(...); struct epoll_event ev ;ev.events = EPOLLIN; ev.data.fd = listen_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 ); close(client_fd);
注意:如果直接 close(fd),内核会自动从 epoll 实例中移除该 fd,但显式调用 EPOLL_CTL_DEL 是更严谨的做法。
epoll 标准使用流程
epoll_create 创建 epoll 句柄
epoll_ctl 把服务端/客户端套接字 注册到事件表
epoll_wait 阻塞等待事件发生
遍历返回的事件数组,只处理有事件的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 #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 ); 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) { if (ep_events[i].data.fd == serv_sock) { 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 ) { 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); } } } } 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 ./echo_epollserv 9527 connected client: 5 closed client: 5 ^C ./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 ./echo_epollserv 9527 return epoll_wait connected client: 5 return epoll_wait ./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, ... ) ;
常用参数(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字节)。
第一次 read:读 2 字节(”he”)→ 返回 2。
第二次 read:读 2 字节(”ll”)→ 返回 2。
第三次 read:读 1 字节(”o”)→ 返回 1。
第四次 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 #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 ); 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 ; } puts ("return epoll_wait" ); for (i = 0 ; i < event_cnt; ++i) { if (ep_events[i].data.fd == serv_sock) { 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 ) { 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); } } } } } 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 ./echo_EPETserv 9527 return epoll_waitconnected client: 5 return epoll_waitconnected client: 6 return epoll_waitreturn epoll_waitreturn epoll_waitreturn epoll_waitreturn epoll_waitreturn epoll_waitclosed client: 6 return epoll_waitclosed client: 5 ^C ./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 ./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, const pthread_attr_t * restrict attr, void *(*start_routine)(void *), void *arg) ; int pthread_join (pthread_t thread, void **status) ; 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 #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_join 或 pthread_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 #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; 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 ; } void *thread_inc (void *arg) { int i; for (i = 0 ; i < 500000 ; i++) num += 1 ; return NULL ; } 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取到值0;
线程1将值加1;
线程1将结果返回(值1);
线程2取到值1;
线程2将值减1;
线程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> pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;int pthread_mutex_init (pthread_mutex_t *mutex, const pthread_mutexattr_t *attr) ;int pthread_mutex_destroy (pthread_mutex_t *mutex) ;int pthread_mutex_lock (pthread_mutex_t *mutex) ;int pthread_mutex_unlock (pthread_mutex_t *mutex) ;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 #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…
如果忘记解锁,那么别的先线程来访问资源发现还被锁着,用不了,这种情况就是“死锁”。
上面我们在递增函数线程中扩大了临界区,用lock、unlock囊括住了整个循环,这是为了减少上锁和解锁调用次数,但是这样的话,一个线程跑完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> int sem_init (sem_t *sem, int pshared, unsigned int value) ;int sem_post (sem_t *sem) ;int sem_wait (sem_t *sem) ;int sem_destroy (sem_t *sem) ;int sem_trywait (sem_t *sem) ;sem_timedwait(sem_t *sem, const struct timespec *abs_timeout) int sem_getvalue (sem_t *sem, int *sval) ;
返回值 :除了’wait’和’getvalue’,成功返回 0,失败返回 -1 并设置 errno。
sem_post 和 sem_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 #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_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); 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); 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 #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; int *pclient = malloc (sizeof (int )); *pclient = clnt_sock; if (pthread_create(&tid, NULL , handle_client, pclient) != 0 ) { perror("pthread_create" ); close(clnt_sock); free (pclient); } else { pthread_detach(tid); } } 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 #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); } 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 #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 ); } 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 ./chat_server 9527 Connected client IP: 127.0.0.1 Connected client IP: 127.0.0.1 ./chat_client 127.0.0.1 9527 gmq Connected........ hi -> gmq: hi -> cola: 干嘛 不干嘛,就hi一下 -> gmq: 不干嘛,就hi一下 -> cola: 鸡你 太美? -> gmq: 太美? -> cola: yeah q ./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, SIZE_T dwStackSize, LPTHREAD_START_ROUTINE lpStartAddress, LPVOID lpParameter, DWORD dwCreationFlags, LPDWORD lpThreadId ) ;
1 2 3 4 5 6 7 8 9 10 #include <process.h> uintptr_t _beginthreadex( void *security, unsigned stack_size, unsigned (__stdcall *start_address)(void *), void *arglist, unsigned initflag, unsigned *thrdaddr );
推荐使用 _beginthreadex。C/C++ 运行时库(CRT)中有一些函数使用了静态/全局变量 (例如 errno、strtok 的内部指针、gmtime 的静态缓冲区)。如果直接用 CreateThread 创建线程,CRT 没有机会为这些变量分配线程局部存储(TLS) ,导致多线程环境下这些函数互相干扰、结果错乱或崩溃。
_beginthreadex 做的事情 :
在调用 CreateThread 之前,分配并初始化 CRT 所需的 TLS 索引。
调用 CreateThread 创建真正的系统线程。
在新线程启动时,自动设置正确的 TLS 数据,让每个线程拥有独立的 errno、strtok 状态等。
因此,只要你的线程函数中可能用到任何 CRT 函数(包括 printf、malloc、strtok、errno、fopen 等),就必须使用 _beginthreadex ,否则可能出现难以调试的随机 bug。
纯 Windows API 编程(如只用 WriteFile、MessageBox 等,完全不调用 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 #include <windows.h> #include <process.h> #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 *)¶m, 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状态的情况也有所区别,我们可用 WaitForSignleObject 和 WaitForMultipleObjects 验证内核对象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, const HANDLE *lpHandles, BOOL bWaitAll, 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 #include <windows.h> #include <process.h> #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 *)¶m, 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 #include <windows.h> #include <process.h> #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 可以把 signaled 和 nonsignaled 理解为 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 #include <windows.h> #include <process.h> #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; EnterCriticalSection(&cs); for (i = 0 ; i < 500000 ; ++i) { num += 1 ; } 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, BOOL bInitialOwner, LPCTSTR lpName ) ;
返回值 :成功返回互斥量句柄(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) ;
作用 :释放当前线程对互斥量的所有权。只有当前拥有该互斥量的线程才能调用此函数 ,否则返回 FALSE,GetLastError() 返回 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 #include <windows.h> #include <process.h> #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, LONG lInitialCount, LONG lMaximumCount, LPCTSTR lpName ) ;BOOL ReleaseSemaphore ( HANDLE hSemaphore, LONG lReleaseCount, LPLONG lpPreviousCount ) ;
代码示例:
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 #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); scanf ("%d" , &num); ReleaseSemaphore(semOne, 1 , NULL ); } return 0 ; } unsigned WINAPI Accu (void * arg) { int sum = 0 , i; for (i = 0 ; i < 5 ; ++i) { WaitForSingleObject(semOne, INFINITE); sum += num; ReleaseSemaphore(semTwo, 1 , NULL ); } 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, BOOL bInitialState, LPCTSTR lpName ) ;BOOL SetEvent (HANDLE hEvent) ; BOOL ResetEvent (HANDLE hEvent) ;
代码示例,两个线程同时结束(无固定先后顺序):
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 #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 ); 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); 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 #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 ; } 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 #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; 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 ); } 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 ; } 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 .\chat_serv_win.exe 9527 servaddr size: 16 Connected client IP: 127.0 .0.1 Connected client IP: 127.0 .0.1 .\chat_client_win.exe 127.0 .0.1 9527 gmq hello [gmq ] hello [cola ] 干嘛? 奇变偶不变 [gmq ] 奇变偶不变 [cola ] 符号看象限 鸡 [gmq ] 鸡 [cola ] 你太美 q .\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); scanf ("%d" , &num); ReleaseSemaphore(semOne, 1 , NULL ); } 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不阻塞,是真正的异步;而异步通知 ,是内核通过事件对象主动通知“有事件发生”,但程序仍需要阻塞等待事件对象,等待期间线程挂起。
重点在”通知”二字上,回顾之前学习的select,select 模型被称为 同步 I/O 模型 ,原因有二:
事件检测是同步的 :你主动调用 select,内核检查 socket 状态,然后返回结果。整个过程是 “你调用,内核回应” 的同步交互,不是内核主动通知。
数据拷贝是同步的 :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。
作用机制
关联 :将套接字 s 与事件对象 hEventObject 关联,并指定内核需要监测的网络事件。
状态变化 :当套接字上发生任何一个 lNetworkEvents 中指定的事件时,内核会将该事件对象设置为有信号(signaled) 。
等待 :用户线程可以调用 WSAWaitForMultipleEvents() 等待该事件对象(或多个事件对象)。
获取详情 :事件对象有信号后,调用 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,可用于任何等待函数(如 WaitForSingleObject、WaitForMultipleObjects),但推荐使用 WSAWaitForMultipleEvents(它内部处理了 Alertable 等 Winsock 特殊需求)。
注意 :
不需要时调用 WSACloseEvent 关闭。
每个 socket 通常对应一个独立的事件对象(也可以多个 socket 共享一个事件,但不推荐,因为无法区分具体哪个 socket 就绪)。
2. WSACloseEvent 1 2 3 <winsock2.h> BOOL WSACloseEvent (WSAEVENT hEvent) ;
参数 :
返回值 :
成功:非零(TRUE)
失败:FALSE,可调用 WSAGetLastError() 获取错误码。
说明 :
关闭由 WSACreateEvent 创建的事件对象,释放内核资源。
关闭后该句柄不可再用。
使用时机 :
当套接字关闭时,应先 WSAEventSelect(sock, NULL, 0) 解除关联(避免残留),再关闭事件对象。
或者直接关闭套接字并关闭事件对象(但解除关联更安全)。
等待一个或多个事件对象变为有信号。返回满足条件的索引或超时,是事件通知模型的核心等待函数。
1 2 3 4 5 6 7 8 <winsock2.h> DWORD WSAWaitForMultipleEvents ( DWORD cEvents, const WSAEVENT *lphEvents, BOOL fWaitAll, DWORD dwTimeout, BOOL fAlertable ) ;
返回值 :
成功且 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, LPWSANETWORKEVENTS lpNetworkEvents ) ;
参数 :
s:要查询的套接字。
hEventObject:与该套接字关联的事件对象句柄(由 WSAEventSelect 设置)。如果提供,该函数会自动将事件对象重置为无信号 。如果不需要重置,可以传 NULL。
lpNetworkEvents:指向 WSANETWORKEVENTS 结构体的指针,用于接收发生的事件及错误码。
返回值 :
成功:0
失败:SOCKET_ERROR,可调用 WSAGetLastError()。
WSANETWORKEVENTS 结构体 :
1 2 3 4 typedef struct _WSANETWORKEVENTS { long lNetworkEvents; int iErrorCode[FD_MAX_EVENTS]; } WSANETWORKEVENTS;
lNetworkEvents:位掩码,表示当前发生的所有事件。可以检查 if (netEvents.lNetworkEvents & FD_READ)。
iErrorCode:索引使用 FD_READ_BIT、FD_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 ) { } } if (events.lNetworkEvents & FD_CLOSE) { } }
注意 :
调用 WSAEnumNetworkEvents 后,事件对象被重置,但如果有新的事件在调用之后立即发生,事件对象会再次被内核设为有信号,不会丢失。
该函数只返回自上次调用之后发生的事件(或关联后尚未处理的事件)。多次调用之间,内核会累积事件,直到被枚举出来。
工作流程总结
初始化 :WSAStartup
创建事件 :event = WSACreateEvent()
关联 :WSAEventSelect(socket, event, FD_READ | FD_CLOSE)
收集事件句柄 :将所有 socket 的事件句柄放入数组
等待 :index = WSAWaitForMultipleEvents(count, events, FALSE, INFINITE, FALSE)
获取详情 :WSAEnumNetworkEvents(socket, event, &netEvents)
处理事件 :根据 netEvents.lNetworkEvents 分支处理
重复 :回到步骤 5(事件已自动重置,可再次等待)
清理 :对每个 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 ;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)) { } else if (netEvents.lNetworkEvents & FD_READ) { } 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 #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!" ); newEvent = WSACreateEvent(); if ( WSAEventSelect(hServSock, newEvent, FD_ACCEPT) == SOCKET_ERROR) ErrorHandling("WSAEventSelect() error!" ); hSockArr[numOfClntSock] = hServSock; hEventArr[numOfClntSock] = newEvent; numOfClntSock++; while (1 ) { 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; 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 .\AsyncNotifyEchoServ_win.exe 9527 servaddr size: 16 connected new client... .\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操作的基石。
关键 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 #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 ;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(); 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; 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!" ); 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 ) { 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 ); 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 ); 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); WSACloseEvent(pClient->event); pClient->valid = FALSE; eventArray[i] = eventArray[clientCount - 1 ]; clients[i] = clients[clientCount - 1 ]; clientCount--; i--; } } } } } 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 .\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 .\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 .\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 回调
调用带 Ex 或 Alertable 的等待函数
内核自动在 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 #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 ;void CALLBACK CompletionRoutine (DWORD dwError, DWORD cbTransferred, LPWSAOVERLAPPED lpOverlapped, DWORD dwFlags) ;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)); 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); } void CALLBACK CompletionRoutine (DWORD dwError, DWORD cbTransferred, LPWSAOVERLAPPED lpOverlapped, DWORD dwFlags) { 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; return ; } printf ("Received %d bytes from %d: %.*s\n" , cbTransferred, pClient->sock, cbTransferred, pClient->buf); send(pClient->sock, pClient->buf, cbTransferred, 0 ); 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!" ); 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 ]); while (1 ) { CleanupClients(); 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); } } SleepEx(100 , TRUE); } 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, HANDLE ExistingCompletionPort, ULONG_PTR CompletionKey, DWORD NumberOfConcurrentThreads ) ;
示例用法:
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> HANDLE hIOCP = CreateIoCompletionPort( INVALID_HANDLE_VALUE, NULL , 0 , 0 ); SOCKET listenSock = WSASocket(AF_INET, SOCK_STREAM, 0 , NULL , 0 , WSA_FLAG_OVERLAPPED); CreateIoCompletionPort( (HANDLE)listenSock, hIOCP, (ULONG_PTR)listenSock, 0 ); 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 ); CloseHandle(hIOCP);
确认完成端口已完成的I/O和现成的I/O处理 1 2 3 4 5 6 7 8 9 10 11 #include <windows.h> BOOL GetQueuedCompletionStatus ( HANDLE CompletionPort, LPDWORD lpNumberOfBytesTransferred, PULONG_PTR lpCompletionKey, LPOVERLAPPED *lpOverlapped, DWORD dwMilliseconds ) ;
示例用法:
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 ) { BOOL success = GetQueuedCompletionStatus( hIOCP, &bytesTransferred, &completionKey, &lpOverlapped, INFINITE ); if (!success) { if (lpOverlapped == NULL ) { break ; } PER_IO_CONTEXT* pIo = CONTAINING_RECORD(lpOverlapped, PER_IO_CONTEXT, overlapped); closesocket(pIo->sock); free (pIo); continue ; } PER_SOCKET_CONTEXT* pSocket = (PER_SOCKET_CONTEXT*)completionKey; PER_IO_CONTEXT* pIo = CONTAINING_RECORD(lpOverlapped, PER_IO_CONTEXT, overlapped); if (bytesTransferred == 0 ) { closesocket(pSocket->sock); free (pIo); free (pSocket); continue ; } printf ("Received %d bytes from %d\n" , bytesTransferred, pSocket->sock); send(pSocket->sock, pIo->buffer, bytesTransferred, 0 ); 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 #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!" ); hComPort = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL , 0 , 0 ); if (hComPort == NULL ) ErrorHandling("CreateIoCompletionPort() error!" ); 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); } 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); 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)); if (CreateIoCompletionPort((HANDLE)hClntSock, hComPort, (ULONG_PTR)handleInfo, 0 ) == NULL ) { closesocket(hClntSock); free (handleInfo); ErrorHandling("CreateIoCompletionPort(associate) error!" ); continue ; } 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; 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); } } } 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 ) { BOOL success = GetQueuedCompletionStatus( hComPort, &bytesTrans, (PULONG_PTR)&handleInfo, (LPOVERLAPPED*)&ioInfo, INFINITE ); if (!success) { if (ioInfo == NULL ) { printf ("Worker thread exiting...\n" ); break ; } printf ("I/O error on socket %d\n" , handleInfo->hClntSock); closesocket(handleInfo->hClntSock); free (handleInfo); free (ioInfo); continue ; } if (ioInfo->rwMode == 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); 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); } } } else if (ioInfo->rwMode == WRITE) { free (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 .\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 .\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 .\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端口):
这里的路径是根路径/,服务端必须要处理这个路径,需要返回index.html:
1 2 3 4 5 6 7 8 9 10 char path[256 ] = "/index.html" ; if (strcmp (requested_path, "/" ) == 0 ) { sprintf (full_path, "./index.html" ); } else { sprintf (full_path, ".%s" , requested_path); }
如果自己指定了端口号,就要在访问时指定端口:
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:9527Connection : keep-aliveUser-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请求没有请求体,所以这里为空)
必须解析的内容只有两处:
接着看看响应消息的结构和如何解析:
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.html → Content-Type: text/html; charset=utf-8
返回 cat.jpg → Content-Type: image/jpeg
Content-Length(灵魂字段) :告诉浏览器这次返回的响应体有多少个字节。
浏览器必须拿到这个数字,去比对收到的数据长度,才能知道文件有没有传完。这个字段不能错。
空行 :字符串里必须有一个单独的\r\n,放在头部和文件内容之间。
源码参考代码仓库中thread文件夹下的web_server.c
至此,tcp/ip网络编程的所有内容(书上的)都已经学习完毕。
Chapter25 秃头之路 本章是进阶学习的一些建议,大致就是说网络编程和系统编程关系密切,为学习系统编程,推荐了 《UNIX环境高级编程》 和 《Windows核心编程》 两本书,为进阶网络编程,推荐了 《TCP/IP详解》(卷1~卷3) 和 《TCP/IP协议族》 。
作者说的,总体意思是需要多学多练多问,但是不管是学,还是练,还是问,都要带入思考,不能看到一个东西不会,就直接搜索答案或者问别人。日积月累,勤学勤思勤练,才能成为一名合格的程序员,这样在专业领域才能有所建树,无论是网络编程,还是图形编程,还是驱动软件…
所有内容就此结束了,没有什么意外应该不会再更新此博客,如果还有网络编程的相关内容想写,我应该会单独开文章写,祝愿大家都能慢慢成长,步履不停,做出自己满意的作品~
酱内~ ξ( ✿>◡❛) THE END.