tcp/ip编程

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

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

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

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

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

Chapter1 理解网络编程和套接字

1.1 理解网络编程和套接字

网络编程和套接字概要

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

构建“接电话”套接字

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

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

编写“Hello World!”服务器端

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

{.line-numbers}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// hello_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
void error_handling(char *message);

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

struct sockaddr_in serv_addr;
struct sockaddr_in clnt_addr;
socklen_t clnt_addr_size;

char message[]="Hello World!";

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

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

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

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

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

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

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

void error_handling(char *message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
  • 24:socket
  • 33:bind
  • 36:listen
  • 40:accept

构建打电话(请求连接)套接字(客户端)

1
2
#include <sys/socket.h>
int connect(int sockfd, struct sockaddr *serv_addr, socklen_t addrlen); // 成功0,失败-1

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

{.line-numbers}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// hello_client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
void error_handling(char *message);

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

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

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

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

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

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

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

return 0;
}

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

在Linux平台下运行

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

先开服务端监听

1
./hserver 9190

再运行客户端请求连接

1
./hclient 127.0.0.1 9190

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

1
Message from server : Hello World!

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

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

1.2 基于Linux的文件操作

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

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

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

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

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

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

打开文件

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

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

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

关闭文件

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

1
2
3
4
#include <unistd.h>

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

将数据写入文件

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

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

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

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

读文件中的数据

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

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

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

文件描述符与套接字

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

1.3 基于Windows平台的实现

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

同时学习Windows和Linux的原因

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

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

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

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

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

Winsock的初始化

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

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

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

使用MAKEWORD传递版本信息

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

“Winsock初始化公式”

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

注销Winsock

1
2
3
#include <winsock2.h>

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

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

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

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

基于Windows的套接字相关函数

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

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

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

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

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

创建基于Windows的服务器端和客户端

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

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

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

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

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

项目结构

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

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

执行服务端

1
.\server.exe 9091

执行客户端与结果

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

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

{.line-numbers}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// hello_server_win.c
#include <stdio.h>
#include <stdlib.h>
#include <winsock2.h>
void ErrorHandling(char* message);

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

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

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

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

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

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

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

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

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

return 0;
}

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

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

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

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

{.line-numbers}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// hello_client_win.c
#include <stdio.h>
#include <stdlib.h>
#include <winsock2.h>
#include <ws2tcpip.h>
void ErrorHandling(char* message);

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

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

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

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

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

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

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

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

void ErrorHandling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
  • 20:WSAStartup初始化Winsock库
  • 23、32:socket创建套接字、connect通过套接字向服务器发出连接请求
  • 35:recv接收服务器发来的数据
  • 42:WSACleanup注销Winsock库

基于Windows的I/O函数

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

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

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

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

Chapter2 套接字类型与协议设置

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

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

关于协议(protocol)

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

创建套接字

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

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

协议族(Protocol Family)

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

sys/socket.h中声明的PF

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

套接字类型(type)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

协议的最终选择

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

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

1
2
int tcp_socket = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
int udp_socket = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP);

面向连接的套接字:TCP套接字示例

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

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

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

Chapter3 地址族与数据序列

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

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

网络地址(Internet Address)

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

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

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

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

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

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

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

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

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

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

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

  • A类地址首位以0开始
  • B类地址前2位以10开始
  • C类地址前3位以110开始

用于区分套接字的端口号

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

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

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

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

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

3.2 地址信息的表示

表示IPv4地址的结构体

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

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

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

结构体sockaddr_in成员分析

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

之前我们调用bind是这样的

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

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

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

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

3.3 网络字节序与地址变换

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

简单来说就是

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

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

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

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

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

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

字节序(Order)与网络字节库

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

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

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

字节序转换(Endian Conversion)

帮助转换字节序的函数

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

网络地址初始化

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

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

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

INADDR_ANY

上面的代码中我们用到了

1
sin_addr.s_addr = htonl(INADDR_ANY);

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

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

向套接字分配网络地址

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

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

int bind(int sockfd, struct sockaddr* myaddr, socklen_t addrlen); // S:0 F:-1

Windows和Linux使用的函数

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

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

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

4.1 理解TCP和UDP

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

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

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

TCPIPvsOSI

TCP/IP协议栈

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

TCP/IP协议栈

链路层

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

TCP/UDP层

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

应用层

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

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

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

函数调用顺序

进入等待连接请求状态

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

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

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

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

受理客户端请求

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

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

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

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

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

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

客户端函数调用顺序

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

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

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

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

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

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

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

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

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

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

实现迭代服务器端

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

迭代服务器端

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

迭代回声服务器端/客户端

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

服务端

{.line-numbers}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// server_echo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(char* message);

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

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

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

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

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

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

clnt_addr_size = sizeof(clnt_addr);

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

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

close(clnt_sock);
}

close(serv_sock);

return 0;
}

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

客户端

{.line-numbers}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// client_echo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h> // write read 声明的地方
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(char* message);

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

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

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

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

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

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

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

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

close(clnt_sock);
return 0;
}

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

echo客户端存在的问题

上面的代码中 line 45~48

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

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

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

5.1 echo客户端的完美实现

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

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

修改上面客户端的程序

{.line-numbers}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// client_echo2.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h> // write read 声明的地方
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(char* message);

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

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

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

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

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

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

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

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

close(clnt_sock);
return 0;
}

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

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

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

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

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

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

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

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

5.2 Windows echo客户端实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// client_echo2_win.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <winsock2.h>

#define BUF_SIZE 1024

void error_handling(char* message);

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

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

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

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

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

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

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

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

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

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

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

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

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

5.3 TCP原理

TCP套接字中的I/O缓冲

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

I/O缓冲的特性如下

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

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

TCP内部工作原理

TCP通信分为三步

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

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

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

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

1
[SYN] SEQ: 1000, ACK: -

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

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

(2) 然后B向A发送

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

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

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

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

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

(3) 最后A向B发送

1
[SYN] SEQ: 1001 ACK: 2001

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

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

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

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

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

四次握手

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

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

TCP比UDP慢的原因在于:

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// udp_server_echo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 32
void error_handling(char* message);

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

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

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

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

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

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

close(serv_sock);
return 0;
}

void error_handling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// udp_client_echo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h> // write read 声明的地方
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 32
void error_handling(char* message);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Chapter7 优雅地断开套接字连接

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

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

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

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

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

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

Windows下与Linux下半关闭相似

1
2
3
#include <winsock2.h>

int shutdown(SOCKET sock, int howto);

howto的三个值: SD_RECEIVE、SD_SEND和SD_BOTH

下面是一个传输文件的demo

Linux服务端,将名称为hello.txt的文件和服务端放置在同一目录下再运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
// linux_FileTransmitServer.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <arpa/inet.h>

#define BUF_SIZE 64
#define MULTI_CLIENT 1

void error_handling(const char* msg);

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

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

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

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

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

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

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

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

clnt_addr_size = sizeof(clnt_addr);

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

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

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

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

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

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

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

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

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

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

Windows客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
// windows_FileTransmitClient.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <winsock2.h>
#include <ws2tcpip.h> // inet_pton

#define BUF_SIZE 64

void error_handling(const char* msg);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

return 0;
}

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

Chapter8 域名及网络系统

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

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

利用域名获取IP地址

1
2
3
#include <netdb.h>

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

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

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

hostent结构体

利用下面这段代码测试gethostbyname

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// getip.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <arpa/inet.h>
#include <netdb.h>
void error_handling(char* msg);

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

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

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

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

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

利用IP获取域名

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// getdomain.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netdb.h>
void error_handling(const char* msg);

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

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

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

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

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

return 0;
}

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

Windows上也有类似的函数

1
2
3
#include <winsock2.h>

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

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

Chapter9 套接字的多种可选项

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

Options

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

get/set 套接字特性

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// getype.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>

void error_handling(const char* msg);

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

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

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

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

return 0;
}

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

运行结果

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

控制I/O缓冲大小

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

使用选项 SO_SNDBUFSO_RCVBUF 设置

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

读取一下前面写的那么些个程序中创建的默认tcp socket的缓冲区大小

获取输入/输出缓冲大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// get_buf.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>

void error_handling(const char* msg);

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

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

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

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

return 0;
}

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

运行结果

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

设置输入/输出缓冲大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// set_buf.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>

void error_handling(const char* msg);

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

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

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

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

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

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

return 0;
}

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

运行结果

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

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

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

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

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

TimeWait

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

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

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

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

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

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

Nagle算法(TCP_NODELAY控制)

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

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

现代开发用法

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

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

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

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

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

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

查看

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

Windows 下 getsockopt / setsockopt 详解

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

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

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

// 获取套接字选项
int getsockopt(SOCKET s, int level, int optname, char* optval, int* optlen);
// 成功返回 0,失败返回 SOCKET_ERROR(-1)
参数 含义
SOCKET s 要操作的套接字句柄(服务端/客户端socket都可)
int level 选项所属层级(核心两个):1. SOL_SOCKET:套接字通用层(IP/端口相关);2. IPPROTO_TCP:TCP协议层(TCP算法相关)
int optname 具体选项名(本次用两个:SO_REUSEADDR / TCP_NODELAY
const char* optval 指向选项值的指针(传入要设置的参数)
int optlen 选项值的字节长度setsockopt 为入参)
char* optval 接收选项值的缓冲区(getsockopt 为出参)
int* optlen 入参:缓冲区大小;出参:实际返回长度(仅getsockopt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// set sendbuf/recvbuf
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <winsock2.h>
void ErrorHandling(char *message);
void ShowSocketBufSize(SOCKET sock);

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

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

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

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

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

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

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

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

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

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

Chapter10 多进程服务器端

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

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

进程的概念和应用

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

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

重点掌握用fork()函数创建进程——这是多进程服务器的核心操作。fork()函数由父进程调用,调用后会创建一个与父进程几乎完全相同的子进程,子进程会复制父进程的内存空间、代码段、数据段等资源,唯一的区别是fork()的返回值:父进程中返回子进程的PID,子进程中返回0,我们可以通过这个返回值区分父子进程,从而让它们执行不同的逻辑(比如父进程继续监听客户端连接,子进程负责与单个客户端通信)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// fork.c
#include <stdio.h>
#include <unistd.h>

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

gval++, lval += 5;

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

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

return 0;
}

示例结果:

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

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

进程和僵尸进程

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

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

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

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

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

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

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

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

return 0;
}

执行结果:

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

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

僵尸进程

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

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

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

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

函数声明:

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// wait.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

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

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

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

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

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

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

示例结果:

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

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

下面讲 waitpid

函数声明:

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

核心参数 & 返回值:

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

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

  • WIFEXITED(status):判断子进程是否正常退出
  • WEXITSTATUS(status):获取子进程的退出返回值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// waitpid.c
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

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

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

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

return 0;
}

示例结果:

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

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

信号处理

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

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

signal 函数(过时,仅了解)

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

参数说明

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

返回值

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

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

alarm 函数(定时发送信号)

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

参数说明

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

返回值

  • 之前设置的闹钟剩余秒数,无闹钟则返回0

作用:定时触发信号,配合信号处理函数使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// signal.c
#include <stdio.h>
#include <unistd.h>
#include <signal.h>

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

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

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

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

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

return 0;
}

示例结果:

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

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

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

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

成员说明

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

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

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

参数说明

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

返回值

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <stdio.h>
#include <unistd.h>
#include <signal.h>

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

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

alarm(2);

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

示例结果:

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// remove_zombie.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>

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

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

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

示例结果:

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

基于多任务的并发服务器

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
// echo_mpserv.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30

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

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

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

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

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

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

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

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

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

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

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

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

示例结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 终端中运行服务器 ./echo_mpserv 9527
# 多开几个终端模拟多客户端连接 ./client_echo2 127.0.0.1 9527
Connected........
Input message(Q to quit): hello
Message from server: hello
Input message(Q to quit): 人类的本质是
Message from server: 人类的本质是
Input message(Q to quit): 复读鸡你太美babyoh~
Message from server: 复读鸡你太美babyoh~
Input message(Q to quit): Q

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

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

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

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

分割TCP的I/O程序

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// echo_mpclient.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30

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

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

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

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

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

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

close(sock);
return 0;
}

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

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

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

示例结果:

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

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

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

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

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

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

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

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

简单总结:

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

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

Chapter11 进程间通信

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

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

进程间通信的基本概念

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

pipe 函数详解:

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

参数说明

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

返回值

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

核心特性:管道是半双工通信,数据只能单向流动(写端→读端)。

代码示例:单向管道通信(父<->子 单方向传输)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// pipe1.c
#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 30

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

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

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

示例结果:

1
2
# ./pipe1
Who are you?

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// pipe2.c
#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 30

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

pipe(fds);
pid = fork();

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <stdio.h>
#include <unistd.h>
#define BUF_SIZE 30

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

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

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

示例结果:

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

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

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

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

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

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

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

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

运用进程间通信

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
// echo_storeserv.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 30

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

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

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

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

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

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

pipe(fds);
pid = fork();

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

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

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

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

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

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

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

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

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

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

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

Chapter12 I/O复用

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

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

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

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

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

多进程服务器的核心缺陷

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

I/O 复用服务器的优势

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

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

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

通俗类比理解

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

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

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

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

select 函数标准声明:

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

参数解释:

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

返回值(3种情况):

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

select 核心配套工具:

1. fd_set 数组

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

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

2. 四个操作宏

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

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

3. struct timeval 超时结构体

设置 select 的等待超时时间:

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

select 标准调用流程

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

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

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

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

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

代码示例(监视标准输入,5秒超时):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// select.c
#include <stdio.h>
#include <unistd.h>
#include <sys/time.h>
#include <sys/select.h>

#define BUF_SIZE 30

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

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

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

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

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

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

示例结果:

1
2
3
4
5
6
7
8
9
# ./select                     
Time-out!
Time-out!
hello
message from console: hello
Time-out!
鸡你太美
message from console: 鸡你太美
^C

实现I/O复用服务器端

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
// echo_selectserv.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <sys/select.h>

#define BUF_SIZE 100

void error_handling(char *buf);

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

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

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

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

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

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

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

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

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

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

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

示例结果:

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

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

基于Windows的实现

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

select函数声明

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

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

参数说明

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

返回值

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

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

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

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

timeval 结构体:

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

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

fd_set 操作宏

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
// echo_selectserv_win.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <winsock2.h>

#define BUF_SIZE 1024

void ErrorHandling(char *message);

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

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

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

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

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

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

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

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

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

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

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

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

示例结果:

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

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

Chapter13 多种I/O函数

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

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

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

send & recv 函数

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

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

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

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

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

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

返回值含义:

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

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

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

补充说明:

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// flags_oob_send.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#define BUF_SIZE 30

void error_handling(char *message);

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

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

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

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

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

close(sock);
return 0;
}

void error_handling(char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// flags_oob_recv.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#define BUF_SIZE 30

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

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

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

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

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

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

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

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

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

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

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

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

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

windows + wsl2 运行结果:

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

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

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

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

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

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

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// flags_peek_send.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>

void error_handling(char *message);

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

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

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

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

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

void error_handling(char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// flags_peek_recv.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <fcntl.h>

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

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

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

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

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

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

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

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

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

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

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

运行结果:

1
2
3
4
5
6
7
# 终端 1
./flags_peek_recv 9527
# 终端 2
./flags_peek_send 127.0.0.1 9527
# 终端 1 输出
Bufferring 3 bytes: 123
Read(recv) again. Bufferring 3 bytes: 12

可见发送的数据被读取了两次,其中第一次为预读取,只是读取内容,不清楚缓冲区,第二次读取才清缓冲区。

readv & writev 函数

readvwritev分散/聚集 I/O 函数,专为多段不连续数据设计。

相比普通 read/write 只能操作单块缓冲区,它们可以一次性读写多块内存,无需手动拼接数据,大幅减少系统调用次数,是高性能网络编程的常用函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <sys/uio.h>

// 核心结构体:描述一块数据缓冲区
struct iovec {
void *iov_base; // 数据缓冲区的起始地址
size_t iov_len; // 缓冲区的长度(字节)
};

// 分散读:从文件/套接字读数据,放入多块缓冲区
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);

// 聚集写:将多块缓冲区数据,一次性写出
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);

这两个函数不依赖套接字,文件、管道、套接字都能使用;writev 是网络编程中发送「协议头+消息体」的最优方案

参数 含义
fd 文件/套接字描述符(通用型,可用于网络通信)
iov struct iovec 结构体数组,存放多块数据的地址+长度
iovcnt 结构体数组的元素个数(即要操作的缓冲区数量

返回值含义

  1. 正整数:成功读写的总字节数
  2. 0readv 专用,代表读到文件末尾/对方关闭连接
  3. -1:函数调用发生错误

核心功能

  • readv 分散读:一次读取,自动拆分存入多个不连续缓冲区
  • writev 聚集写:多段零散数据,一次性合并发送,无需拼接内存

writev代码示例:

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

int main() {
struct iovec vec[2];
int str_len;
char buf1[] = "ABCDEFG";
char buf2[] = "1234567";

vec[0].iov_base = buf1;
vec[0].iov_len = 3;
vec[1].iov_base = buf2;
vec[1].iov_len = 4;

str_len = writev(1, vec, 2);
puts(""); // 换行
printf("Write bytes length: %d\n", str_len);

return 0;
}

writev运行结果:

1
2
3
./writev
ABC1234
Write bytes length: 7

readv代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// readv.c
#include <stdio.h>
#include <sys/uio.h>

#define BUF_SIZE 100

int main() {
struct iovec vec[2];
int str_len;
char buf1[BUF_SIZE] = {0, };
char buf2[BUF_SIZE] = {0, };

vec[0].iov_base = buf1;
vec[0].iov_len = 5;
vec[1].iov_base = buf2;
vec[1].iov_len = BUF_SIZE;

str_len = readv(0, vec, 2);
printf("Read bytes length: %d\n", str_len);
printf("Read first buf: %s\n", buf1);
printf("Read second buf: %s\n", buf2);

return 0;
}

readv运行结果:

1
2
3
4
5
./readv
hello world hihihi i'm lee
Read bytes length: 27
Read first buf: hello
Read second buf: world hihihi i'm lee

总结就是能用就用,可以减少writeread调用次数,何乐而不为呢?

基于 Windows 的实现(处理MSG_OOB消息)

Windows 没有 Linux 下的 SIGURG 信号机制,无法通过触发信号来处理 TCP 紧急消息;但 select 模型支持监听异常文件描述符集合,而通过 Out-of-Band 传输的 MSG_OOB 紧急数据,会被系统判定为异常事件,因此我们完全可以依靠 select 的异常监听来统一处理紧急消息。

代码示例(send没什么差别,主要差别在recv):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// oob_send_win.c
#include <stdio.h>
#include <stdlib.h>
#include <winsock2.h>
#include <ws2tcpip.h>

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

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

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

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

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

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

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

send(hSocket, "123", 3, 0);
send(hSocket, "4", 1, MSG_OOB);
send(hSocket, "567", 3, 0);
send(hSocket, "890", 3, MSG_OOB);

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

void ErrorHandling(char* message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
// oob_recv_win.c
#include <stdio.h>
#include <stdlib.h>
#include <winsock2.h>

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

int main(int argc, char* argv[]) {
WSADATA wsaData;
SOCKET hRecvSock, hAcptSock;
SOCKADDR_IN recvAddr, sendAddr;
int sendAddrSize, strLen;
char buf[BUF_SIZE];
int result;

fd_set read, except, readCopy, exceptCopy;
struct timeval timeout;

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

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

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

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

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

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

sendAddrSize = sizeof(sendAddr);
hRecvSock = accept(hAcptSock, (SOCKADDR*)&sendAddr, &sendAddrSize);
if (hRecvSock == INVALID_SOCKET)
ErrorHandling("accept() error!");

FD_ZERO(&read);
FD_ZERO(&except);
FD_SET(hRecvSock, &read);
FD_SET(hRecvSock, &except);

while (1) {
readCopy = read;
exceptCopy = except;
timeout.tv_sec = 5;
timeout.tv_usec = 0;

result = select(0, &readCopy, 0, &exceptCopy, &timeout);

if (result > 0) {
// 监听到异常消息
if (FD_ISSET(hRecvSock, &exceptCopy)) {
strLen = recv(hRecvSock, buf, BUF_SIZE - 1, MSG_OOB);
buf[strLen] = 0;
printf("Urgent Msg: %s\n", buf);
}

// 监听到普通消息
if (FD_ISSET(hRecvSock, &readCopy)) {
strLen = recv(hRecvSock, buf, BUF_SIZE - 1, 0);
if (strLen == 0) {
break;
closesocket(hRecvSock);
} else {
buf[strLen] = 0;
puts(buf);
}
}
}
}

closesocket(hAcptSock);
WSACleanup();

return 0;
}

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

运行结果:

1
2
3
4
5
6
7
8
# 终端1
.\oob_recv_win.exe 9527
# 终端2
.\oob_send_win.exe 127.0.0.1 9527
# 终端1 输出
Urgent Msg: 4
12356789
Urgent Msg: 0

感觉这玩意儿比发信号好使多了,倒不如都用select。

然后就是,windows里面没有和writevreadv功能相似的函数,可以通过重叠I/O达成目的,先了解一下有这么个实现方式。