C++ TCP/IP 网络编程
TCP/IP
所有学习都要在开始前认识到其必要性!
套接字的一些相关函数
open
打开文件:int open(const char *path, int flag);
参数:文件名的字符串地址,文件打开模式信息
头文件:<sys/types.h>, <sys/stat.h>, <fcntl.h>
close
关闭文件:int close(int fd);
参数:需要关闭的文件或套接字的文件描述符
头文件:<unistd.h>
write 函数
1 |
|
write
函数用于将数据从缓冲区写入文件描述符 fd
指定的文件或套接字。它的参数如下:
fd
:文件描述符,可以是一个文件描述符、套接字描述符等,用于指定要写入的目标。buf
:一个指向存储要写入数据的缓冲区的指针。count
:要写入的字节数。
write
函数返回成功写入的字节数,如果发生错误,返回-1,并设置全局变量 errno
表示具体的错误原因。
使用示例:
1 |
|
在上面的示例中,我们打开一个文件并使用 write
函数将数据写入文件中。
read 函数
1 |
|
read
函数用于从文件描述符 fd
指定的文件或套接字中读取数据并存储到缓冲区 buf
中。它的参数如下:
fd
:文件描述符,可以是一个文件描述符、套接字描述符等,用于指定要读取的源。buf
:一个指向存储读取数据的缓冲区的指针。count
:要读取的最大字节数。
read
函数返回实际读取的字节数,如果到达文件尾(EOF)或发生错误,返回值会小于请求的字节数。如果发生错误,它返回-1,并设置 errno
表示错误原因。
使用示例:
1 |
|
socket
函数:int socket(int domain, int type, int protocol);
参数:协议族信息(IP4、6等),套接字数据传输方式类型信息(SOCK_STREAM, SOCK_DGRAM
),计算机间通信使用的协议信息
若第三个参数的前两个参数是
PF_INET、SOCK_STREAM
,则第三个参数IPPROTO_TCP
可省略。
头文件:<sys/socket.h>
面向连接的套接字: 即 SOCK_STREAM,传输数据是无边界的。也就是说 write 几次,或者 read 几次是无所谓的。接收数据的次数和传输数据的次数可以不同。 它以字节流的形式传输数据,这意味着数据被视为连续的字节流,而不是离散的消息。在 TCP 中,应用程序将数据写入套接字(使用
write
或类似的函数),数据被发送到远程端点,但不会被分割成消息或记录。接收方从套接字中读取数据(使用read
或类似的函数),并根据需要将字节组装成消息或记录。
面向无连接的套接字: 即 SOCK_DGRAM,传输数据是有边界的。接收数据的次数和传输数据的次数应该相同。 通常情况下,每次调用write
操作(或者发送UDP数据包)都应该对应一次read
操作(或者接收UDP数据包)。每个write
操作生成一个独立的UDP数据包,而每个read
操作接收一个UDP数据包。由于UDP是面向消息的,每个UDP数据包都是一个独立的消息单元,因此在接收端,您通常需要通过一次read
操作来接收一个完整的消息。
地址族和数据序列
网络地址和主机地址
IPv4 地址分为 网络地址 和 主机地址(共 4 个字节,即 32 位)。且分为 A、B、C、D、E 五种类型。
A 类(0127):左 1 字节为 网络地址,右 3 字节为 主机地址。191):左 2 字节为 网络地址,右 2 字节为 主机地址。
B 类(128
C 类(192~223):左 3 字节为 网络地址,右 1 字节为 主机地址。
D 类:4 个字节为 多播 IP 地址。
假如向 www.baidu.com 传输数据,首先传输到 网络地址,再通过路由器或交换机浏览数据的主机地址并将数据传输到目标计算机。(P37)
套接字端口号
端口号:用于区分同一操作系统中不同套接字设置的。(例如 网页播放器,通过不同端口号区分是传输给网页套接字的数据还是传输给播放器套接字的数据)
范围 0-65535,但是 0-1023 已被分配。TCP 和 UDP 这两种不同的套接字可以用同一端口,相同类型套接字则不行。
地址信息表示(结构体)
POSIX 定义的数据类型:
sin_family
:AF_INET
或 AF_INET6
或 AF_LOCAL
。
sin_port
和 sin_addr
都以 网络字节序 保存(仅在给该结构体参数时需要进行字节序的转换)。
sockaddr 是 sockaddr_in 的复杂原型:
字节序 转换
网络字节序 统一为 大端序。
s 代表两个字节 short,一般用于 端口号 转换。
l 代表四个字节 long,一般用于 ip地址 转换。
网络地址初始化与分配
ip 地址转换 字符串
头文件:arpa/inet.h
sockaddr_in
中的地址信息为 32 位整型。即需要将 ip 地址(点分十进制形式)转为 32 位整型,可通过函数 inet_addr
转换(可检测无效 ip 地址,即某个字节超过255):
inet_aton
也实现转换 ip 地址,但是还会保存返回值到 in_addr
类型变量中(也就是结构体中存 ip 地址的成员)。
inet_ntoa
将网络字节序整数型 ip 地址转换为字符串(需要及时保存到字符串中,即字符数组;否则,再次调用函数可能会覆盖之前转换的字符串信息)。
初始化网络地址 sockaddr_in 结构体
将所有成员初始化为 0,是为了将结构体成员 sin_zero 初始化为 0。
INADDR_ANY
:(常数)自动获取运行服务器端的计算机 IP 地址。
服务器端优先考虑使用该方式。
addr.sin_addr.s_addr = htonl(INADDR_ANY)
bind 将套接字进行初始化(即绑定地址信息)
bind
函数用于将一个本地地址(IP地址和端口号)绑定到一个套接字。这是在服务器套接字上非常常见的操作,以指定服务器在哪个本地地址上监听客户端连接。
以下是 bind
函数的详细信息:
1 |
|
sockfd
:是要绑定的套接字的文件描述符。addr
:是一个指向struct sockaddr
结构体的指针,包含要绑定的本地地址信息。addrlen
:是addr
结构体的大小,以字节为单位。
struct sockaddr
结构体是一个通用的地址结构,它的具体类型(struct sockaddr_in
或 struct sockaddr_in6
)取决于你使用的地址族(IPv4或IPv6)。通常,你需要将地址信息填充到一个适当类型的 sockaddr
结构体中,然后将其强制转换为 struct sockaddr*
。
TCP 服务器端/客户端 实现
TCP 服务器端默认函数调用顺序
listen 函数:
等待连接请求状态,门卫
参数:服务器套接字文件描述符,连接请求等待队列的长度(若是5,表示最多使 5 个连接请求进入队列(休息室))
accept 函数:
自动创建套接字,并连接到发起请求的客户端,即受理连接请求等待队列中待处理的客户端连接请求
参数:服务器套接字文件描述符,保存发起请求的客户端的地址信息的变量地址值,第二个参数的长度
TCP 客户端的默认函数调用顺序
connect 函数:
客户端发起连接请求。
客户端调用 connect 函数后,发生下列情况之一才会返回:
服务器端接收连接请求。
不一定使服务器端调用 accept 函数,是服务器端把连接请求信息记录到等待队列。
发生断网等异常情况中断了连接请求。
客户端套接字也需要分配 ip 和 端口: 调用 connect 函数时自动 由 操作系统(内核),使用计算机(主机)的IP,端口随机。(无需 bind)
迭代服务器端(通过循环)
此时同一时刻只能服务于一台客户端(即需要重新调用accept才能同一时刻服务多个客户端)。需要线程和进程,才能服务多个客户端。
TCP 的 I/O 缓冲
调用 write 不会直接输出,而是先将数据保存到 输出缓冲区。
也就是说 write 不会在完成向对方主机数据传输时返回,而是在数据移到输出缓冲时返回。TCP 会确保缓冲数据的传输。
调用 read 也不会直接输入,而是先将数据保存到输入缓冲区。
UDP
TCP 和 UDP 区别:流控制机制。
TCP 在不可靠的 IP 层进行流控制。
TCP 套接字传输的数据为数据包。
UDP 套接字传输的数据为数据报(也可以叫数据包),因为 UDP 存在数据边界,1 个数据包即可成为一个完整数据,因此叫数据报。
一个 UDP 套接字就能和多台主机通信。
基于 UDP 的数据 I/O 函数
创建好 TCP 套接字,则传输数据时无需再添加地址信息,因为会一直保持连接。
而 UDP 不会保持连接状态(如邮筒),每次传输数据都要添加目标地址信息。
sendto:发送 UDP 数据的函数。向指定地址传输数据。
调用 sendto 函数时,进行客户端的 ip 地址(host)和端口(随机)的分配
sendto 传输数据流程:
每次调用都重复该流程。因此可以用同一 UDP 套接字向不同目标传输数据。
recvfrom:接收 UDP 数据的函数。
已连接(connected)UDP套接字和未连接(unconnected)UDP套接字
已连接 UDP 套接字: 指注册了目标地址信息(通过 sendto 函数)。
如果需要向同一主机发送多次数据(即多次调用 sendto 到同一主机),则已连接 UDP 套接字更为合适,提高效率。
未连接 UDP 套接字: 未注册目标地址信息。
创建 已连接 UDP 套接字:
使用 connect 函数:
对 UDP 套接字调用 connect 函数不意味着与对方 UDP 套接字连接,仅向 UDP 套接字注册目标 ip 和端口信息。
优雅地断开套接字连接
仅断开一部分连接:可以传输数据但无法接收,可以接收数据但无法传输。
避免 A 主机关闭了连接,导致 B 主机向 A 主机发送的必要数据被销毁。
如图,该部分需要仅断开一部分流,而 close 会同时断开两个流。
shutdown 函数(优雅断开)
可传输 EOF。
是一种半关闭流的函数。仅关闭其中一个流。
断开连接的方式:
SHUT_RD(断开输入流):套接字无法接收数据,即使输入缓冲收到数据也会被抹去,而且无法调用输入相关的函数。
SHUT_WR(断开输出流):无法传输数据。但输出缓冲还有未传输的数据,则将传递给目标主机。
SHUT_RDWR(同时断开 I/O 流):指两次调用 shutdown,一次 SHUT_RD,一次 SHUT_WR。
域名及网络地址
通过域名获取 IP 地址
头文件:#include <netdb.h>
h_name: 存有官方域名。
**h_aliases: ** 其它域名,即可通过多个域名访问同一主页。
h_addrtype: 获取保存在 h_addr_list 中的 IP 地址的地址族信息。若是 IPv4,则该变量存有 AF_INET。
h_length: 保存 IP 地址长度。IPv4:4个字节,为 4。IPv6:16个字节,为16。
h_addr_list: 以整数形式保存域名对应的 IP 地址。可能分配多个 IP 给同一域名。
结构体成员 h_addr_list 指向字符串指针数组(由多个字符串地址构成的数组)。但字符串指针数组中的元素实际指向的是 in_addr 结构体变量地址值而非字符串。
因为也为 ipv6 准备,所以 hostent 中的 h_addr_list 成员不是 in_addr 结构体指针数组,而是 char 指针。
1 | inet_ntoa(*(struct in_addr*)host->h_addr_list[i]); |
利用 IP 地址获取域名
in_addr 类型是 struct sockaddr_in 类型中的 sin_addr 成员类型。
套接字的多种可选项
通过 getsocketopt & setsockopt 进行可选项的读取与设置
getsocketopt: 读取套接字选项。
第五个参数指的是:保存第四个参数的大小(也就是被传递过来的数据的大小)
套接字类型只能在创建时决定,以后不能再更改。
setsocketopt: 更改套接字可选项。
更改 SO_SNDBUF (输出缓冲) & SO_RCVBUF (输入缓冲)
SO_REUSEADDR
无论是服务器端还是客户端断开连接(发送 FIN 包),都需要进入一个 TIME_WAIT 时间的状态(避免主机发送的ACK消息丢失,导致另一台主机重发 FIN 包)。
也就是说套接字经过四次握手过程后并非立即消除,而是要经过一段时间的 Time-wait 状态。
此时,服务器端先断开连接,是无法立即重新运行的。(也就会导致 bind 函数调用出错)
而客户端端口号是任意指定的,所以每次运行程序会动态分配端口号(应该与之前不同,所以没有问题)。
SO_REUSERADDR:调整该参数,可将 Time-wait 状态下的套接字端口号重新分配给新的套接字。
默认值为 0,即无法重新分配。
改为 1 即可重新分配。
TCP_NODELAY
Nagle 算法: 防止因数据包过多而发生网络过载。
TCP 套接字默认使用 Nagle 算法交换数据,不会消耗大量网络流量(Traffic)。
但如果是 “传输大文件”,则不使用Nagle 算法更合适。
也就是说,Nagle 算法使用与否在网络流量上差别不大,使用 Nagle 算法的传输速度更慢。
TCP_NODELAY 默认值为 0,即开启 Nagle 算法。
禁用 Nagle 算法,即改为 1(true)。
多进程服务器端
代表性并发服务器端实现模型与方法:
- 多进程服务器:通过创建多个进程提供服务。
- 多路复用服务器:通过捆绑并同一管理 I/O 对象提供服务。
- 多线程服务器:通过生成与客户端等量的线程提供服务。
进程
占用内存空间的正在运行的程序。如 运行一个游戏或多个游戏,就是一个进程或多个进程。
进程 ID
ps au:查看所有进程详细信息。
无论进程是如何创建的,都会被操作系统分配到 ID,即 进程 ID。
值为大于 2 的整数,因为 1 被分配给协助操作系统的进程(操作系统启动后的第一个进程)。
fork 函数创建进程
fork 函数复制当前正在运行的、调用 fork 函数的进程。
注意:调用 fork 函数后,如果之前有套接字返回的描述符,会被复制,但是不会复制套接字。
如果复制套接字后,同一端口将对应多个套接字,不合理。
fork 函数调用后,会复制指向一个套接字的文件描述符,也就是父子进程都有一个自己的、指向一个套接字的文件描述符。通过 close 关闭其中一个文件描述符不会影响另一个,除非都关闭,才会彻底销毁套接字。
父进程:fork 函数返回子进程 ID。
也就是调用 fork 函数的主体,原进程。
子进程:fork 函数返回 0。
通过父进程调用 fork 函数复制出来的进程。
可以通过判断返回值,使得 父进程 和 子进程 进行不同操作。
调用 fork 函数后,父子进程拥有完全独立的内存结构。
进程和僵尸进程
进程完成工作后(执行完 main 函数中的程序后)应被销毁,但有时这些进程将变成僵尸进程,占用系统中的重要资源。
产生僵尸进程的原因
调用 fork 函数产生子进程的终止方式:
- 传递参数并调用 exit 函数。
- main 函数中执行 return 语句并返回值。
上述两种方法的参数值或返回值都会传递给操作系统,而操作系统不会销毁子进程,直到把这些值传递给产生该子进程的父进程,此时就是 “僵尸进程”。
如何销毁僵尸进程?
向创建子进程的父进程传递子进程的 exit 参数值 或 return 语句的返回值。
必须要父进程主动发起请求(函数调用)时,操作系统才会传递该值。否则,操作系统将一直保存该结束状态值,并让子进程长时间处于僵尸进程状态。
ps au:可查看僵尸进程(Z+)。
利用 wait 函数
需谨慎调用该函数,若调用后没有已终止的子进程,程序将阻塞(blocking)直到有子进程终止。
调用 wait 函数时如果已有子进程终止,则将子进程终止时传递的返回值保存到该函数参数所值的内存空间(创建一个 int 类型变量,然后将该变量地址作为参数)。
但是该函数参数指向的单元还包含其它信息,需要通过宏分离:使用 waitpid 函数
wait 可能会引起程序阻塞,waitpid 可避免阻塞。
信号处理
信号处理(Siglnaling Handling)机制,如:用于避免上述函数一直等待子进程终止。
信号:当特定事件发生,由操作系统向进程发送的消息(如子进程终止)。
处理:为了响应消息,执行与消息相关的自定义操作。
信号与 signal 函数与 alarm 函数
signal 函数:用于产生某个信号时,调用对应函数。
用于注册信号。当信号发生时,操作系统调用该信号对应的函数。
函数名:signal
参数:int signo, void (* func)(int)
返回类型:参数为 int 型,返回 void 型的函数指针
第一个参数 signo(注册的一些信号):
alarm 函数:设置一定时间后产生 SIGALRM 信号。
参数若传递 0,则之前对 SIGALRM 信号的预约将取消,也就是取消之前的 alarm 函数设定的将要触发的信号。
如果后续代码使用了 sleep(进程处于睡眠状态时无法调用函数),产生信号时,为了调用信号处理器(handler),将唤醒由于调用 sleep 函数而进入阻塞状态的进程,进程一旦被唤醒,就不会再进入睡眠状态。(P169)
sigaction 函数(比 signal 函数更稳定)
signal 函数在 UNIX 系列不同操作系统有区别,但 sigaction 完全相同。
struct sigaction 结构体定义:
sa_handler:保存信号处理函数的指针值(地址值)。
sa_mask 和 sa_flags:用于指定信号相关的选项和特性。仅用于防止僵尸进程时,可初始化为 0。
sigemptyset(&act.sa_mask):将 sa_mask 成员所有位初始化为0。
act.sa_flags = 0;
分割 TCP 的 I/O 程序
通过父子进程分别处理 收、发消息,从而提高频繁交换数据的程序性能。
如图,右侧为分割之后,可以连续发送数据,提高同一时间内传输的数据量。
进程间通信
目的:有助于构建多种类型服务器端。
进程间通信:即两个不同进程间可以交换数据。
需要操作系统提供两个进程可以同时访问的内存空间。
通过管道(pipe)实现进程间通信
管道 如同 套接字 一样非进程的资源,而是属于操作系统(也就不是 fork 函数的复制对象)。从而使两个进程通过操作系统提供的内存空间进行通信。
创建管道
父进程通过调用 fork 函数将入口或出口的文件描述符传递给子进程。
因为将用于管道的 I/O 的文件描述符进行了复制,所以父子进程同时拥有 I/O 文件描述符,即都可读写。不是复制管道。
1 |
|
通过管道进行进程间的双向通信
向管道传递数据时,先读(read)的进程会把数据取走。
因此,仅通过一个管道不能实现双向通信。(P187)
在父进程向管道 write 后,与父进程之后的 read 需要有一段 sleep 时间;否则,子进程,无法读取到父进程 write 的数据,从而会陷入无限等待。
创建两个管道实现进程间的双向通信
运用进程间通信,保存消息回声服务端
创建两个子进程,一个用于读取管道,然后将读取到的数据写入文件;另一个用于接收客户端的数据,并写入管道。(P189,程序代码)
I/O 复用(Multiplexing)
在不创建进程的同时,向多个客户端提供服务。
I/O 复用服务器端的进程需要确认举手(收到数据)的套接字,并通过举手的套接字接收数据。
例如:纸杯电话系统。
以下为 多进程服务器端模型 和 I/O复用服务器端模型 对比。
复用技术可减少进程数,无论连接多少客户端,提供服务的进程只有一个。
select 函数实现复用服务器端
使用 select 函数可以将多个文件描述符集中到一起统一监视。
事件如下:
- 是否存在套接字接收数据?
- 无需阻塞传输数据的套接字有哪些?
- 哪些套接字发生了异常?
select 函数的调用方法和顺序
- 设置文件描述符
通过fd_set
数组变量执行此项操作。也就是将要监视的文件描述符集中在一起,同时也要按照事件(接收、传输、异常)进行区分。
可通过下列宏函数操作 fd_set 数组。
也就是说 通过 FD_SET 宏函数设置一个套接字,该套接字会与 fd_set 数组中的位进行绑定,通过判断 fd_set 数组中的位,即可知道该套接字是否发生事件。发生事件仍为 1,未发生事件则为 0。
- 设置检查(监视)范围及超时
maxfd 是要监视的文件描述符集合中的最大文件描述符加1,因为是从 0 开始。
timeval 结构体的定义如下:
调用 select 函数后查看结果
在调用 select 函数之前,为了保存 fd_set 初始值,需要将准备好的 fd_set 变量的内容复制到另外一个变量进行保存。因为调用 select 函数后,除了发生变化的文件描述符对应位外,剩下的所有位将初始化为 0。(这是使用 select 函数的通用方法,P202)
调用 select 函数前,每次都要初始化 timeval 结构体变量,否则 timeval 成员将被替换为超时前剩余时间。
当 select 函数调用完成后,向其传递的 fd_set 变量将发生变化。
在调用
FD_SET
之后,如果select
函数返回并且相应的文件描述符上发生了事件(例如可读、可写、异常等),那么相应的位将仍然设置为1。如果
select
函数返回并且相应的文件描述符上没有发生事件,那么相应的位将保持为0。
举例说明:
当 FD_ISSET 的结果是 1 时,也就是对应的 0 (标准输入套接字)发生了事件,则执行 if 语句体内容。否则,为 0 ,则不执行。
实现 I/O 复用服务器端
P203.
多种 I/O 函数
完成数据的输入和输出。
Linux 中 send&recv
send
recv
可选项参数
send 和 recv 的最后一个参数是收发数据时的可选项。该可选项可利用位或(bit OR)运算(| 运算符)同时传递多个信息。
MSG_OOB:发送紧急消息
用于创建特殊发送方法和通道以发送紧急消息(如同医院急诊。)。
传递数据时只返回一个字节,并不会加快数据传输速度,调用带有 MSG_OOB 的函数接收,且超过一个字节的依然是通过调用常用输入函数读取剩余部分。(仅利用了 TCP 的紧急模式(Urgent mode)进行传输)
仅仅用来当发送紧急消息后,可以通过事件处理函数确认。
发送端:通过 send 中使用 MSG_OOB 可选项即可。
接收端:当收到 MSG_OOB 紧急消息时,操作系统将产生 SIGURG 信号,并调用注册的信号处理函数。并且,recv 函数中可选项也是 MSG_OOB。
紧急模式工作原理
例如:send(sock, "890", strlen("890"), MSG_OOB);
紧急指针指向紧急消息的下一个位置。
实际上只用 1 个字节表示紧急消息信息,即除紧急指针的前面 1 个字节外,其它数据通过调用常用输入函数读取。
- URG=1:载有紧急消息的数据包。
- URG指针:紧急指针位于偏移量为 3 的位置。
下图是 TCP 数据包。
fcntl 函数
参考:https://blog.csdn.net/qq_37414405/article/details/83690447
fcntl
函数是一个用于控制文件描述符(File Descriptor,通常是套接字或文件)属性和操作的Unix系统调用。它可以用于执行各种操作,包括文件锁定、非阻塞I/O、获取和设置文件描述符标志等。
以下是 fcntl
函数的常见用法和原型:
1 |
|
返回值:如果出错则返回 -1;成功则根据 cmd 不同,返回值不同。
fd
是要操作的文件描述符。cmd
是要执行的操作,它可以是以下之一:复制一个现有的描述符(cmd=F_DUPFD).
获得/设置文件描述符标记(cmd=F_GETFD或F_SETFD).
获得/设置文件状态标记(cmd=F_GETFL或F_SETFL).
获得/设置异步I/O所有权(cmd=F_GETOWN或F_SETOWN).
获得/设置记录锁(cmd=F_GETLK , F_SETLK或F_SETLKW).
F_DUPFD
:复制文件描述符。F_GETFD
:获取文件描述符标志。F_SETFD
:设置文件描述符标志。F_GETFL
:获取文件状态标志(文件打开方式)。F_SETFL
:设置文件状态标志(文件打开方式)。F_GETLK
:获取文件锁定信息。F_SETLK
:设置文件锁定信息。F_SETLKW
:设置文件锁定信息并等待。
arg
是一个可选的参数,它依赖于cmd
的具体操作。不同的操作可能需要不同类型的参数。
fcntl(recv_sock, F_SETOWN, getpid());
fcntl 函数用于控制文件描述符。
该函数调用的意思是:
套接字拥有者:实际是操作系统,操作系统创建并管理套接字。
”处理 SIGURG 信号“ 指的是 ”调用 SIGURG 信号处理函数“。此处主要是避免通过 fork 复制了文件描述符,从而不知道是子进程调用信号处理函数还是父进程调用。因此,该函数语句指定当前进程为处理 SIGURG 信号的主体,即当前进程(getpid() 返回当前进程 id)。
F_SETOWN
用于设置异步 I/O 信号的接收者。异步 I/O(Asynchronous I/O)允许一个进程在等待 I/O 操作完成的同时继续执行其他任务,而不会阻塞在 I/O 操作上。
以下是 F_SETOWN
的详细解释:
语法
1 | int fcntl(int fd, int cmd, pid_t owner); |
fd
:要操作的文件描述符。cmd
:指定操作类型,这里应该设为F_SETOWN
。owner
:要接收异步 I/O 信号的进程的PID。
作用
当一个进程使用异步 I/O 操作打开文件时,它可以通过 F_SETOWN
来指定接收异步 I/O 信号的目标进程。
一旦 I/O 操作完成,内核会向目标进程发送一个信号来通知它。这个信号的类型是 SIGIO
或者 SIGPOLL
,具体取决于系统和实现。
示例
以下是一个示例,演示了如何使用 F_SETOWN
来设置异步 I/O 信号的接收者:
1 |
|
在上述示例中,我们首先打开了一个文件 example.txt
,然后使用 fcntl
设置了异步 I/O 信号的接收者为当前进程(getpid()
获取当前进程的 PID)。接着,我们将文件设置为异步 I/O 模式,以便在 I/O 操作完成时发送信号。最后,我们安装了一个信号处理器 sigio_handler
来处理接收到的异步 I/O 信号。
请注意,在实际应用中,可能还需要对文件描述符进行错误处理等其他操作。
检查输入缓冲(MSG_PEEK, MSG_DONTWAIT)
通过同时设置 MSG_PEEK 和 MSG_DONTWAIT 选项,验证输入缓冲中是否存在接收的数据。
MSG_PEEK:仅 recv 函数。判断输入缓冲是否有数据,同时,有这个选项的 recv 即使读取了输入缓冲的数据也不会删除。
MSG_DONTWAIT:send 和 recv 都可。用于调用 I/O 函数时不阻塞。
上述函数搭配使用,可判断输入缓冲是否有数据,同时保证了不存在待读取数据也不会进入阻塞状态。
如下,如果有数据,则跳出循环。
readv & writev 函数
通过 writev 函数可以将分散保存在多个缓冲中的数据一并发送,通过 readv 函数可以由多个缓冲分别接收。
适当使用这两个函数,可减少 I/O 函数的调用次数。
writev
**const struct iovec *iov**:指的是 iovec 数组指针。因此该函数可以输出多个缓冲区的数据。iov 中保存有多个数组(如多个字符串)。
- iov_base: 用于指定数据要读取的地址。
- iov_len:从指定地址处读取前 iov_len 个字符。
结构体结构如下:
readv
iovec 结构体中的:
- iov_base:用于将读入数据保存的地址。
- iov_len:因为 iov 有多个结构体,因此,这个指定当前地址能保存 iov_len 个字符。也就是接收的最大字节数。
合理使用 readv & writev 函数
- 需要传输的数据位于不同的缓冲
- 减少数据包的个数(减少了 I/O 调用次数,提高了性能)
多播与广播
多播技术:解决向大量客户端发送相同数据。主要用于“多媒体数据的实时传输”。
基于 UDP 完成。
多播数据传输特点:
多播组:D 类 IP 地址(224.0.0.0 ~ 239.255.255.255)。
- 224.0.0.0 到 224.0.0.255 用于预留的多播地址,通常不用于一般的应用,而用于协议和管理目的。
- 224.0.1.0 是IPv4所有主机的多播地址,用于在特定子网中向所有主机发送多播数据。
- 224.0.0.0 和 224.0.0.1 用于多播路由器协议(如OSPF)。
- 224.0.0.9 用于RIPv2路由协议。
其余多播 IP 地址都能作为多播组 IP。
多播(Broadcast)
路由(Routing)和 TTL(Time to Live,生存时间),以及加入组的方法
TTL
Time to Live:决定“数据包传递距离”。TTL 用整数表示,每经过 1 个路由器就减 1。TTL 变为 0 时,该数据包无法再被传递,只能销毁。
TTL 值设置过大将影响网络流量,过小则无法传递到目标。
设置 TTL
通过设置套接字可选项。
协议层:IPPROTO_IP
选项:IP_MULTICAST_TTL
加入多播组
协议层:IPPROTO_IP
选项:IP_ADD_MEMBERSHIP
ip_mreq 结构体
imr_multiaddr.s_addr:写入加入的组 IP 地址。
imr_interface.s_addr:加入该组的套接字所属主机的 IP 地址(可使用 INADDR_ANY)
实现多播 Sender 和 Receiver
多播中用 Sender 和 Receiver 名称替代服务端和客户端。
具体代码:P233
广播(Broadcast)
多播即使在跨越不同网络的情况下,只要加入多播组就能接收数据。
广播只能向同一网络中的主机传输数据。(也是基于 UDP)
根据传输数据时使用的 IP 地址的形式不同,广播分为 2 种:
直接广播(Directed Broadcast)
除了网络地址外,其余主机地址全部设置为1。
如希望向网络地址 192.12.34 中的所有主机传输数据时,可以向 192.12.34.255 传输。
也就是说 直接广播 用于向特定区域内所有主机传输数据。
本地广播(Local Broadcast)
本地广播 使用的 IP 地址限定为 255.255.255.255。
如,192.32.24 网络中的主机向 255.255.255.255 传输数据时,数据将传递到 192.32.24 网络中的所有主机。
广播示例中数据通信中使用的 IP 地址是与 UDP 示例的唯一区别。
默认生成的套接字会阻止广播。
因此通过套接字可选项 SO_BROADCAST 更改默认设置 为 1 即可,这意味着可以进行数据广播,仅需要在 Sender 中修改。
套接字和标准 I/O
使用标准 I/O 函数收发数据优点:
具有良好的移植性(按照 ANSI C 标准)。
可以利用缓冲提供性能。
创建套接字时,操作系统会准备 I/O 缓冲,这里的缓冲是用于执行 TCP 协议的。
标准 I/O 函数提供了另外的缓冲支持。简单来说,有缓冲后,可以一次性发送更多的数据,从而加快了时间,减少了头信息的冗余数据。
使用标准 I/O 函数收发数据缺点:
- 不容易进行双向通信
- 有时可能频繁调用 fflush 函数
- 需要以 FILE 结构体指针的形式返回文件描述符。
对套接字使用标准 I/O 函数
需要将套接字返回的文件描述符转换为 FILE 结构体指针。
需要注意在使用 标准 I/O 函数后,需要调用 fflush 函数,否则无法保证立即将数据传输到客户端。
fdopen 函数(fd -> FILE*)
将文件描述符转换为文件指针。
mode:与 fopen 函数的打开模式相同。常用的有:读模式“r”,写模式“w”。
fileno 函数(FILE* -> fd)
与 fopen 函数相反,将 文件指针 转换为 文件描述符。
关于 I/O 流分离的其他内容
调用 fopen 函数打开文件后可以与文件交换数据,因此说调用 fopen 函数后创建了“流”(Stream)。这里的“流”指数据流动,可以称为:以数据收发为目的的一种桥梁。流可以理解为:数据收发路径。
- 通过 fork 复制出了一个文件描述符,以区分输入和输出中使用的文件描述符。
- 通过 2 次 fdopen 函数,分别创建读模式和写模式的 FILE 指针来区分。
通过 fdopen 文件指针的分离方式
通过 fclose 关闭文件指针,会发送 EOF,但是,不是半关闭连接,而是完全终止了套接字。
原因:读、写模式的 FILE 指针都是基于同一个文件描述符创建的,因此,针对任意一个 FILE 指针调用 fclose 函数都会关闭文件描述符,也就是终止套接字。
解决办法:通过复制指向套接字的文件描述符(dup&dup2函数,复制后的文件描述符的值与原本的不同,但是依然指向对应的套接字)
可以理解为:为了访问同一文件或套接字,创捷另一个文件描述符。
dup 和 dup2 函数
用于复制文件描述符。
dup2 可以指定复制后产生的文件描述符值。
将复制后的 文件描述符 传入 fdopen 函数,转换为 FILE 指针,从而调用标准 I/O 函数。同时,也可以进入半关闭状态。
通过 fileno 函数,将 FILE 指针转换为 文件描述符,再通过 调用shutdown 函数(无论复制出多少文件描述符,都进入半关闭状态)使服务器端进入半关闭状态,并向客户端发送 EOF。
优于 select 的 epoll
select 的优缺点
select 的 缺点:
需要循环,来找到发生变化的对象
每次调用 select 函数都需要传递新的监视对象的信息。
select 是借助于操作系统帮助监视套接字变化的函数,因此每次调用都会向操作系统传递监控对象信息。
select 的 优点:
epoll 只在 Linux 下提供支持,而 select 的兼容性好。
- 服务器端接入者少。
- 程序应具有兼容性。
epoll 函数
epoll 的优点与 select 的缺点正好相反:
- 无需编写以监视状态变化为目的的针对所有文件描述符的循环语句。
- 调用对应于 select 函数的 epoll_wait 函数时,无需每次传递监控对象信息。
epoll 服务器端实现中的三个函数
epoll_create:创建保存 epoll 文件描述符的空间。
epoll 方式下由操作系统负责保存监视对象文件描述符,因此需要向操作系统请求创建文件描述符的空间,即使用 epoll_create。
类似 select 中直接声明 fd_set 变量。
epoll_ctl:向空间注册并注销文件描述符。
epoll 方式中,通过 epoll_ctl 函数请求操作系统进行添加和删除监视对象文件描述符。
类似 select 中调用 FD_SET、FD_CLR 函数。
epoll_wait:与 select 函数类似,等待文件描述符发生变化。
epoll 方式下,调用 epoll_wait 函数等待文件描述符的变化。
类似 select 方式中调用 select 函数等待文件描述符的变化。
select 方式通过 fd_set 变量查看监视对象的状态变化(即事件发生与否),而 epoll 方式通过如下结构体 epoll_event 将发生变化的(发生事件的)文件描述符单独集中到一起。
epoll_create
调用 epoll_create 函数时创建的文件描述符保存空间称为“epoll 例程”。通过参数 size 传递的值决定 epoll 例程的大小,但这只是向操作系统的建议。
epoll_create 创建的资源与套接字相同,也由操作系统管理。返回的文件描述符主要用于区分 epoll 例程,当要终止时,也要调用 close 函数。
Linux 2.6.8 之后的内核完全忽略传入 epoll_create 函数的 size 参数,因为内核会根据情况调整 epoll 例程的大小。
epoll_ctl
epoll_ctl 用于在通过 epoll_create 生成的例程内部注册监视对象文件描述符。
1 |
|
参数 op:
- EPOLL_CTL_ADD:将文件描述符注册到 epoll 例程。
- EPOLL_CTL_DEL:从 epoll 例程中删除文件描述符。
- EPOLL_CTL_MOD:更改注册的文件描述符的关注事件发生情况。
参数 event:
使用方式:
可以通过位或运算符同时传递下面多个参数。
epoll_wait
参数 events 所指缓冲需要动态分配。
EPOLL_SIZE 需要自己 #define。
条件触发和边缘触发
条件触发(Level Trigger):只要输入缓冲中还剩有数据,就将以事件方式再次注册。
边缘触发(Edge Trigger):输入缓冲收到数据时仅注册 1 次该事件,即使输入缓冲中还有数据,也不会再注册。
区别在于发生事件的时间点。
条件触发:只要输入有缓冲就会一直通知该事件,也就是说当服务器端读取了输入缓冲的一部分后,输入缓冲仍有数据,则仍会注册事件。(不停的报告压岁钱的变化)
边缘触发:输入缓冲收到数据时仅注册 1 次该事件,即使输入缓冲中还有数据,也不会再注册。(仅报告第一次收到压岁钱)
实现边缘触发的必知两点
通过 errno 变量验证错误原因
int errno 为 Linux 声明的全局变量。
需要引入 error.h 头文件。
如:read 函数中发现输入缓冲中没有数据可读时,返回 -1,同时在 errno 中保存 EAGAIN 常量。
为了完成非阻塞 I/O,更改套接字特性。
将套接字改为非阻塞方式
除了该套接字flag,还要在epoll_event中位或上EPOLLET(监听边缘触发)。
边缘触发方式下,以阻塞方式工作的 read & write 函数有可能引起服务器端的长时间停顿。
因此,边缘触发方式中一定要采用非阻塞 read & write 函数。
边缘触发和条件触发优劣
边缘触发:可以分离接收数据和处理数据的时间点。也就是当延迟处理数据时,边缘触发方式只会注册一次该事件,而条件触发方式如果延迟处理输入缓冲的消息将会重复注册多次事件,造成服务器性能降低。
多线程服务器端的实现
使用线程为多个客户端提供服务。(逐渐舍弃进程,因为线程实现 Web 服务器端更高效)
1 个 CPU(CPU 的运算设备 CORE)也能同时运行多个进程,是因为系统将 CPU 时间分成多个微小的块后分配给了多个进程。而分时使用 CPU 则需要“上下文切换” 。
上下文切换:运行程序前需要将相应进程信息读入内存,如果运行进程 A 后需要紧接着运行进程 B,就应该将进程 A 相关信息移出内存,并读入进程 B 相关信息,这就是 上下文切换。但此时进程 A 的数据将被移动到硬盘,所以上下文切换需要很长时间。
线程
为了保持多进程优点,在一定程度上克服缺点,就引入了 线程(Thread),是一种“轻量型进程”。
- 线程的创建和上下文切换比进程的创建和上下文切换更快。
- 线程间交换数据时无需特殊技术。(进程需要 IPC 技术)
进程的内存结构
保存全局变量的“数据区”
向malloc等函数的动态分配提供空间的堆(Heap)
函数运行时使用的栈(Stack)构成。
线程的内存结构
线程为了保持多条代码执行流,只分离了栈区域。
上下文切换时不需要切换数据区和堆。
可以利用数据区和堆交换数据。
进程和线程和操作系统之间的关系
- 进程:在操作系统构成单独执行流的单位。
- 线程:在进程构成单独执行流的单位。
进程在操作系统内部生成多个执行流,那么线程就在同一进程内部创建多条执行流。
线程的创建和运行
线程创建方法基于 POSIX 标准(不仅适用于Linux,还适用于大多数 UNIX)。
线程具有单独的执行流,因此需要单独定义线程的main函数,还需要请求操作系统在单独的执行流中执行该函数。
线程的创建函数
pthread_create
创建示例:P287
线程相关代码在编译时需要添加 -lpthread 选项声明需要连接线程库,只有这样才能调用头文件 pthread.h 中声明的函数。
进程中创建线程的执行流程:
图中说明,需要通过 sleep 给线程执行提供执行时间。如果没有足够的执行时间,则进程终止,线程也将被终止。
pthread_join 函数控制线程执行流:
pthread_join
函数用于等待一个指定的线程终止。调用线程(通常是主线程)会一直阻塞,直到指定的线程执行完毕。- 当线程调用
pthread_join
时,它会等待被指定线程的终止,并且可以获取该线程的退出状态(返回值)。 - 调用
pthread_join
后,被指定的线程的资源不会被释放,直到调用pthread_join
并成功返回。 pthread_join
用于确保主线程等待其他线程执行完毕,以便对线程的结果进行处理。
调用该函数的进程(或线程)将进入等待状态,直到第一个参数为ID的线程终止为止。而且可以得到线程的main函数返回值。
返回值是 thread_main 函数内部动态分配的内存空间地址值,因此最后需要释放(free)(?好像不需要)。
多线程调用函数注意事项
部分函数内部存在临界区(Critical Section),创建多线程调用这些函数可能引起问题。
线程安全函数(Thread-safe function)
多线程同时调用不会引起问题。
非线程安全函数(Thread-unsafe function)
多线程同时调用会引起问题。
非线程安全函数一般都提供了对应的线程安全函数(Linux 中以 _r 为结尾的函数),如:
非线程安全函数:
struct hostent * gethostname(const char * hostname);
对应的线程安全函数:
除了手动调用,还可以通过在声明头文件前定义 _REENTRANT
宏。也可以在编译时添加 -D_REENTRANT
定义宏。
工作(Worker)线程模型
假如 main 函数创建了两个线程,两个线程分别做不同的事情,而 main 线程进行管理这两个线程,也就是 main 线程管理这两个线程工作(Worker)。
线程存在的问题和临界区
当多个线程访问同一个内存空间时(如数据区的全局变量),进行更改该内存空间(变量)时,可能会导致出错。任何内存空间–只要被同时访问,都有可能发生问题。
原因:在线程 1 更改完值之前,线程 2 可能通过切换的到 CPU 资源,从而导致线程 1 完成运算后,但是结果还未写到变量中。(P296)
解决办法:当某个线程访问(全局、被共享的)变量时,应在当前线程完成操作前阻止其他线程访问。这就叫做同步(Synchronization)。
临界区位置
临界区通常位于由线程运行的函数内部,即引起问题的语句(如访问修改全局变量的语句)。
线程同步(解决线程问题)
线程同步用于解决线程访问顺序引发的问题。
同时访问同一内存空间时发生的情况
需要指定访问同一内存空间的线程执行顺序的情况
需要控制(Control)线程执行顺序。
如:线程 A 先写入数据,线程 B 后读取数据,该顺序不能错。
互斥量(Mutex)和信号量(Semaphore)
两种同步技术。
互斥量(相当于一把锁,锁机制)
互斥量(Mutual Exclusion)表示不允许多个线程同时访问。主要用于解决线程同步访问的问题。(如临界区(卫生间))
互斥量的创建与销毁:
mutex:先声明 pthread_mutex_t 型变量,再传入该变量地址。(当多个线程需要调用该变量,则需要声明为全局变量)
attr:传递即将创建的互斥量属性,没有填 NULL。
除了使用上述函数创建互斥量,还可以通过宏(PTHREAD_MUTEX_INITIALIZER)声明:
建议使用函数初始化,使用宏的话很难发现发生的错误。
1 | pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; |
利用互斥量锁住或释放临界区时的函数:
进入临界区前调用的函数就是 pthread_mutex_lock。调用该函数时,发现有其他线程已进人临界区,则 pthread mutex lock 函数不会返回,直到里面的线程调用 pthread mutex unlock 函数退出临界区为止。也就是说,直到其他线程让出临界区之前(unlock),当前线程将一直处于阻塞状态。
死锁: 若当线程退出临界区时,没有调用 pthread_mutex_unlock 函数,则其他为了进入临界区而调用pthread mutex lock函数的线程就无法摆脱阻塞状态。
竞态条件:官方的定义是如果程序运行顺序的改变会影响最终结果,这就是一个竞态条件(race condition)。多线程环境中对同一个文件的操作要加锁。
信号量
信号量的创建与销毁
信号量中类似互斥量的lock、unlock函数
1 | sem_wait(&sem); // 信号量变为 0 |
也就是说,信号量默认值是 1,线程 A 首先调用了 sem_wait 函数将信号量减少了 1,变为了 0。此时,又因为信号量为 0 的情况下线程 B 调用 sem_wait 函数,调用该函数的线程 B 将进入阻塞状态。除非等到线程 A 执行完临界区的代码,再调用 sem_post 函数使得信号量增加 1,即变回 1。这时,其他线程如线程 B 跳出阻塞状态了,将继续执行。
主要目的:当一个线程调用 sem_wait 函数进入临界区后,在该线程调用 sem_post 函数前不允许其他线程进入临界区。信号量的值在 0 和 1 之间跳转,因此也称为“二进制信号量”。
线程的销毁
Linux 线程并不是在首次调用的线程 main 函数返回时自动销毁,需加以明确(否则由线程创建的内存空间将一直存在):
调用 pthread_join 函数。
调用该函数时,会等待线程终止,还会引导线程销毁。
但是,线程终止前,调用该函数的线程将进入阻塞状态。
调用 pthread_detach 函数。
调用该函数引导销毁线程创建的内存空间,不会引起线程终止或进入阻塞状态。
调用之后不能再针对相应线程调用 pthread_join 函数。
pthread_detach 函数
pthread_detach
函数用于将一个线程设置为分离状态。分离状态的线程在终止时会自动释放其资源,不需要其他线程调用pthread_join
来回收资源。- 分离状态的线程不能被等待,也不能获取其返回值,因为调用
pthread_detach
后,该线程的资源会在终止时被自动回收,不需要其他线程干预。 - 分离状态的线程通常用于执行一些后台任务,主线程不需要等待它们执行完毕。
多线程并发服务器端的实现
P307.(P317)