Linux高性能服务器编程

Linux网络编程基础API


探讨Linux网络编程基础与内核中TCP/IP协议族之间的关系,并为后续章节提供编程基础

从三方面来讨论Linux网络API

  • socket地址API,socket最开始的含义是一个IP地址和端口对,它唯一的标识了TCP通信的一端
  • socket基础API,socket的主要的API都定义在 sys/socket.h头文件中
  • 网络信息API,Linux提供了一套网络信息的API,以实现主机名与IP地址,服务名与端口号之间的转换

socket地址API


0.字节序

在了解socket地址API之前我们应该先了解一下字节序:

字节序的分类

  • 大端字节序(big endian)

​ 大端字节序是指一个整数的高位字节(23-31bit)存储在内存的低地址处,低位字节(0~7bit)存储在内存的高地址处.

  • 小端字节序(little endian)

​ 小端字节序则是指整数的高位字节存储在内存的高地址处,而低位字节则存储在内存的低地址处.

主机字节序和网络字节序

  • 现代PC大多采用小端字节序,因此小端字节序又被称为网络字节序
  • 现在网络传输中的字节序都采用大端字节序,所以大端字节序又被称为网络字节序

1. 通用socket地址

sockaddr

1
2
3
4
5
6
#include<bits/socket.h>
struct sockaddr
{
sa_family_t sa_family; //sa_family成员是地址族类型(sa_family_t)的变量,地址族类型通常与协议族类型对应
char sa_data[14]; //sa_data成员用于存放socket地址值. 但十四字节根本无法存放大多数协议的地址值
}

sa_family成员是地址族类型(sa_family_t)的变量,地址族类型通常与协议族类型对应

协议族与地址族的关系

协议族 地址值含义和长度 地址族 描述
PF_UNIX 文件的路径名,长度可达到108字节 AF_UNIX UNIX本地域协议族
PF_INET 16bit端口号和32bitIPv4地址,共5字节 AF_INET TCP/IPv4协议族
PF_INET6 16bit端口号,32bit流标识,126bitIPv6地址,32bit范围ID,共26字节 AF_INET6 TCP/IPv6协议族
1
2
3
4
5
6
7
#include<bits/socket.h>
struct sockaddr_storage
{
sa_family_t sa_family;
unsigned long int __ss_align;
char __ss_padding[128-sizeof(__ss_align)]; //提供了更大的空间并且是内存对齐的
}

2. 专用socket地址

通用的socket地址不能够很方便的使用,所以Linux为各个协议族提供了专门的socket地址结构体

Unix本地域协议族 sockaddr_un

1
2
3
4
5
6
#include<sys/un.h>
struct sockaddr_un //Unix本地域协议族
{
sa_family_t sin_family; // 地址族:AF_UNIX
char sun_path[108]; // 文件路径名
};

IPV4 sockaddr_in

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
#include<sys/socket.h>
struct sockaddr_in
{
sa_family_t sin_family; // 地址族:AF_INET
u_int16_t sin_port; //端口号,要使用网络字节序表示
struct in_addr sin_addr; //IPv4地址结构第
char sin_zero[8]; //不使用
}
struct in_addr
{
u_int32_t s_addr; //IPv4地址,要用网络字节序表示
}

//sockaddr_in结构体的赋值
struct sockaddr_in addr;
char * serv_port = "9999";
char * serv_ip = "127.0.0.1";
memset(&addr,0,sizeof(addr)); //内存初始化
addr.sin_family = AF_INET; //设置地址族
// addr.sin_addr.s_addr = htonl(INADDR_ANY); //设置ip地址,服务器通常采用INADDR_ANY
addr.sin_addr.s_addr = inet_addr(serv_ip); //客户端,利用地址转行函数来讲字符串转化为二进制整数
addr.sin_port = htons(atoi(serv_port)); // 设置端口

//INADDR_ANY
//自动分配服务器端的IP,不必输入,服务器有限考虑这种方式

IPV6 sockaddr_in6

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include<sys/socket.h>
struct sockaddr_in6
{
sa_family_t sin5_family; //地址族,要用AF_INET6
u_int16_t sin6_port; //端口号,使用网络字节序
u_int32_t sin6_flowinfo; //流信息,应设置为0
struct in6_addr sin6_addr; //IPv6结构体
u_int32_t sin6_scope_id; //scope ID,尚处于实验阶段
}
struct in6_addr
{
unsigned char sa_addr[16]; //IPv6地址,网络字节序表示
}

所有socket地址类型的变量都需要转化为通用的socket地址类型(socketaddr),直接强制转换即可


3. IP地址转换函数

人们习惯用可读性更好字符串来表示IP地址,但编程中我们需要把它们转化为二进制数方能使用。而记录日志则正好相反

1
2
3
4
5
6
7
#include<arpa/inet.h>
in_addr_t inet_addr(const char * strptr);
int inet_aton(const char * cp,struct in_addr* inp);
char * inet_ntoa(struct in_addr_in);
int inet_pton(int af,const char * src,void * dst);
const char * inet_ntop(int af,const void * src,char * dst,socklen_t cnt);

inet_addr
inet_addr将点分十进制字符串标示的IPv4地址转化为用网络字节序整数表示的IPv4地址.失败时返回INADDR_NONE.

inet_aton
inet_aton将点分十进制字符串标示的IPv4地址转化为用网络字节序整数表示的IPv4地址存储到参数inp指向的地址结构中.成功时返回1,失败时返回0

inet_ntoa
inet_ntoa将用网络字节序整数标示的IPv4地址转化为用点分十进制字符串标示的IPv4地址. 但inet_ntoa函数是不可重入的,该函数内部使用一个静态变量存储转化结果,函数的返回值指向该静态内存

inet_pton
inet_pton函数将用字符串表示的IP地址src(用点分十进制字符串标示的IPv4地址或用十六进制字符串表示的IPv6地址)转换成用网络字节序表示的IP地址,并把转换结果存储与dst指向的内存中.

af:指定地址族,可以是AF_INET或者AF_INET6

成功返回1,失败返回0并设置errno

inet_ntop
inet_ntop函数进行和inet_pton相反的转换.前三个参数的含义与inet_pton相同

cnt:制定目标存储单元的大小,使用下面两个宏

1
2
3
#include<netinet/in.h>
#define INET_ADDRSTRLEN 16
#define INET6_ADDRSTRLEN 46

成功时返回目标存储单元的地址,失败返回NULL并设置errno


socket基础API


1. socket()函数:

原函数:

1
int socket(int domain,int type,int protocl);

参数:

domain指明了协议族/域,通常PF_INET、PF_INET6、PF_UNIX等;
type是套接口类型,主要SOCK_STREAM(对应TCP服务)、SOCK_DGRAM(对应UDP服务) 、SOCK_NONBLOCK(新创建的socket设置为非阻塞的 );
protocol一般取为0。

成功时,返回一个小的非负整数值,与文件描述符类似。
作用: socket函数用于创建一个新的socket,也就是向系统申请一个socket资源。


2. bind()函数:

原函数:

1
int bind(int sockfd,const struct sockaddr* myaddr,socklen_t addrlen);

参数:

sockfd 表示socket函数创建的通信文件描述符
addrlen 表示所指定的结构体变量的大小
myaddr 表示struct sockaddr的地址,用于设定要绑定的ip和端口

成功返回0,失败返回-1并设置errno,其中常见的两种errno是:

  • EACCES:被绑定的地址是收保护的地址,仅超级用户能够访问
  • EADDRINUSE:被绑定的地址正在使用中

作用:命名socket. 将一个socket与socket地址绑定称为给socket命名.


3. listen()函数:

原函数:

1
int listen(int sockfd, int backlog);

listen() 函数的主要作用就是将 socket() 函数得到的 sockfd 变成一个被动监听的套接字, 用来被动等待客户端的连接,而参数 backlog 的作用就是设置连接队列的长度。三次握手,建立连接不是 listen() 函数完成的, 而是内核完成的, listen() 函数只是将 sockfd 和 backlog 告诉内核, 然后就返回了
如果有客户端通过 connect() 发起连接请求, 内核就会通过三次握手建立连接, 然后将建立好的连接放到一个队列中,
这个队列称为: 已完成连接队列
listen函数一般在调用bind之后调用,在accept之前调用,调用成功时返回0,失败时返回-1并设置errno


4. accept()函数:

原函数:

1
int accept(int sockfd,struct sockaddr *addr,socklen_t *addrlen);

参数:

  • sockfd:是执行过listen系统调用的监听socket
  • addr:用来获取被接受连接的远端socket地址
  • addrlen:指出addr接收的socket地址的长度
  • 成功时返回一个新的连接socket,失败时返回-1并设置errno

作用
accept() 函数的作用就是在已完成连接队列中取出一个已经建立好的连接。如果这个队列中已经没有已完成连接的套接字, 那么 accept() 就会一直阻塞, 直到取得一个已经建立连接的套接字


5. 数据读写

TCP数据读写

对文件的读写操作read和write同样适用于socket。但是socket编程接口提供了几个专门用于socket数据读写的系统调用, 它们增加了对数据读写的控制。 其中适用于TCP流数据读写的系统调用是:

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

ssize_t read (int fd, void *buf, size_t nbytes);
ssize_t recv(int sockfd,void*buf,size_t len,int flags);

ssize_t write(int fd, const void *buf, size_t n);
ssize_t send(int sockfd,const void * buf,size_t len,int flags);

read

接受或者输入数据

  • fd:显示数据接受对象的文件描述符
  • buf:要保存接收数据的缓冲区地址
  • nbytes:要接收数据的最大字节数
  • 成功时返回接收到的字节数,接收到文件尾或者连接被关闭返回0,失败返回-1

write

向文件或者套接字传递数据.

  • fd:显示数据传输对象的文件描述符
  • buf:保存要传输数据的缓冲地址值
  • n:要传输数据的字节数
  • 成功时返回写入的字节数,失败时返回-1

recv
接受或者输入数据

sockfd:表示数据接收对象的连接的套接字文件描述符
buf:保存接收数据的缓冲地址值
len:可接收的最大字节数
flags传输数据时制定的可选项信息
成功时返回接受的字节数(收到EOF返回0),失败时返回-1;

send
向文件或者套接字传递数据

sockfd:表示与数据传输对象的连接的套接字文件描述符
buf:保存带传输数据的缓冲地址值
len:待传输的字节数
flags传输数据时制定的可选项信息
成功时返回发送的字节数,失败时返回-1

UDP数据读写

socket编程接口中用于UDP数据报读写的系统调用是:

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

ssize_t recvfrom(int sockfd, void* buf, size_t len, int flags, struct sockaddr* src_addr, socklen_t* addrlen);
ssize_t sendto(int sockfd, const void* buf, size_t len, int flags, const struct sockaddr* dest_addr, socklen_t* addrlen);

recvfrom
sock:用于接收数据的UDP套接字文件描述符
buff:保存接收数据的缓冲区地址值
nbytes:可接受的最大字节数,故无法超过参数buff所指的缓冲区大小
flags:可选项参数,若没有则传入0
from:存有发送端地址信息的sockaddr结构体变量的地址值
addrlen:保存参数from的结构体变量长度的变量地址值

sendto
sock:用于传输数据的UDP套接字文件描述符
buff:保存待传输数据的缓冲区地址值
nbytes:待传输的数据长度,以字节为单位
flags:可选项参数,若没有则传递0
to:存有目标地址信息的sockaddr结构体变量的地址值
addrlen:传递的参数to地址值结构体变量长度
成功时返回传输的字节数,失败时返回-1

UDP通信没有连接的概念,每次读取数据都需要获取发送端的socket地址,即参数src_addr所指的内容,addrlen参数则指定该地址的长度

通用数据读写函数

socket编程接口还提供了一对通用的数据读写系统调用。它们不仅能用于TCP流数据,也能用于UDP数据报:

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

ssize_t recvmsg(int sockfd, struct msghdr* msg, int flags);
ssize_t sendmsg(int sockfd, struct msghdr* msg, int flags);

sockfd参数指定被操作的目标socket。 msg参数是msghdr结构体类型的指针 falgs与前面recv,send的相同


6. 实例

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

//服务器端

/**
* @File:serv_1.cpp
* @Descripttion: socket编程服务器端,简单版本(TCP)
* 只接受一个客户端连接,并接收客户端发送的信息显示后就退出
* @Version:
* @Author: zsj
* @Date: 2020-05-03 15:38:31
* @LastEditors: zsj
* @LastEditTime: 2020-05-03 16:20:39
*/
#include<cstdio>
#include<cstdlib>
#include<sys/types.h>
#include<sys/socket.h>
#include<unistd.h>
#include<arpa/inet.h>
#include<cstring>

void show_error(char * info){
printf(info);
exit(-1);
}

int main(int argc,char * argv[])
{
//参数数量不对直接返回
if(argc < 2){
printf("Usage: %s <port>\n",argv[0]);
exit(-1);
}

// 创建一个socket
int listenfd = socket(PF_INET,SOCK_STREAM,0);
if(listenfd == -1){
show_error("socket() error!");
}

// 设置socket地址
struct sockaddr_in serv_addr;
memset(&serv_addr,0,sizeof(serv_addr));
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(atoi(argv[1]));

//分配地址信息,socket命名
int ret = bind(listenfd,(struct sockaddr*)&serv_addr,sizeof(serv_addr));
if(ret == -1){
show_error("bind() error!");
}

//创建一个监听队列
ret = listen(listenfd,5);
if(ret == -1){
show_error("listen() error!");
}

struct sockaddr_in clnt_addr;
socklen_t clnt_addr_sz = sizeof(clnt_addr);
//取出一个连接
int clnt_sock = accept(listenfd,(struct sockaddr*)&clnt_addr,&clnt_addr_sz);
if(clnt_sock == -1){
show_error("accept() error!");
}
else{
printf("get a connection %d\n",clnt_sock);
}

char buffer[128];
//读取数据
ret = read(clnt_sock,buffer,sizeof(buffer));
if(ret < 0){
show_error("read() error!");
}
else if(ret == 0){
printf("client close the connection!");
}
else{
printf("from %d data %d bytes: %s\n",clnt_sock,ret,buffer);
}

close(clnt_sock);
close(listenfd);
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
//客户端

/**
* @File:clnt_1.cpp
* @Descripttion: socket编程,客户端简单版本,(TCP)
* 连接服务器之后,发送数据到服务器之后就退出
* @Version:
* @Author: zsj
* @Date: 2020-05-03 16:06:36
* @LastEditors: zsj
* @LastEditTime: 2020-05-03 16:19:46
*/
#include<cstdio>
#include<cstdlib>
#include<sys/types.h>
#include<sys/socket.h>
#include<unistd.h>
#include<arpa/inet.h>
#include<cstring>


void show_error(char * info){
printf(info);
exit(-1);
}

int main(int argc,char * argv[])
{
if(argc < 3){
printf("Usage: %s <ip> <port>\n",argv[0]);
exit(-1);
}

//创建一个socket
int clnt_sock = socket(PF_INET,SOCK_STREAM,0);
if(clnt_sock == -1){
show_error("socket() error!");
}

//客户端不需要绑定和监听,直接连接服务器
struct sockaddr_in serv_addr;
memset(&serv_addr,0,sizeof(serv_addr));
serv_addr.sin_port = htons(atoi(argv[2]));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = inet_addr(argv[1]);

int ret = connect(clnt_sock,(struct sockaddr*)&serv_addr,sizeof(serv_addr));
if(ret == -1){
show_error("connect() error!");
}
else{
printf("connect to %s success!\n",argv[1]);
}

//发送数据
char buffer[128] = "hello server!!!";
ret = write(clnt_sock,buffer,sizeof(buffer));
if(ret < 0){
show_error("write() error!");
}
else{
printf("send data success!\n");
}

//关闭连接
close(clnt_sock);

}


7. 带外数据

我们需要知道的是数据分为两种,一种是带内数据,一种是带外数据。带内数据就是我们平常传输或者说是口头叫的数据。带外数据就是我们接下来讲的内容。

   许多的传输层都具有带外数据(也称为 经加速数据 )的概念,想法就是连接的某段发生了重要的事情,希望迅速的通知给对端。这里的迅速是指这种通知应该在已经排队了的带内数据之前发送。也就是说,带外数据拥有更高的优先级。带外数据不要求再启动一个连接进行传输,而是使用已有的连接进行传输。其中,UDP没有实现带外数据(是个极端哦~)。TCP中telnet,rlogin,ftp等应用(除了这样的远程非活跃应用之外,几乎很少有使用到带外数据的地方)

70.png

紧急字段URG:告诉系统此报文段中有紧急数据,应尽快传送。当URG=1时。

紧急指针:指出在本报文段中的紧急数据的最后一个字节的序号,即指出带外数据字节在正常字节流中的位置。

所以当TCP发送带外数据时,他的TCP首部一定是设置了URG标志和紧急指针的 。而紧急指针就是用来指出带外数据字节在正常字节流中的位置的 。


8. 带外标记

内核通知应用程序带外数据到达的两种常见方式是:I/O复用产生的异常事件和SIGURG信号。

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

int sockatmark(int sockfd);

sockatmark判断sockfd是否处于带外标记,即下一个被读取到的数据是否是带外数据。

如果是,sockatmark返回1,此时我们就可以利用MSG_OOB标志的recv调用来接收带外数据。 如果不是,则sockatmark返回0。


9. 地址信息函数

在某些情况下,我们想知道一个连接socket的本端socket地址, 以及远端的socket地址。 下面这2个函数正是用于解决这个问题:

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

int getsockname(int sockfd, struct sockaddr* address, socklen_t* address_len);
int getpeername(int sockfd, struct sockaddr* address, socklen_t* address_len);

getsockname获取sockfd对应的本端socket地址,并将其存储于address参数 指定的内存中,该socket地址的长度则存储于address_len参数指向的变量中。 如果实际socket地址的长度大于address所指内存的大小, 那么该socket地址将被截断。

  • getsockname成功时返回0,失败返回-1,并设置errno。

getpeername获取sockfd对应的远端socket地址, 其参数及返回值的含义与getsockname的参数及返回值相同。


10. socket选项

如果说fcntl系统调用是控制文件描述符属性的通用POSIX方法, 那么下面两个系统调用则是专门用来读取和设置socket文件描述符属性的方法:

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

int getsockopt(int sockfd, int level, int option_name, void* option_value,socklen_t* restrict option_len);
int setsockopt(int sockfd, int level, int option_name, const void* option_value, socklen_t option_len);

sockfd参数指定被操作的目标socket。 level参数指定要操作哪个协议的选项(即属性),比如IPv4、IPv6、TCP等。 option_name参数则指定选项的名字。 option_value和option_len参数分别是被操作选项的值和长度。

  • getsockopt和setsockopt这两个函数成功时返回0,失败时返回-1并设置errno。

对服务器而言,有部分socket选项要在监听(listen)前针对监听socket设置才有效。 对客户端而言,这些socket选项则应在调用connect函数之前设置, 因为connect调用成功之后,TCP三次握手已完成。


网络信息API


1. gethostbyname和gethostbyaddr

gethostbyname 函数根据主机名称获取主机的完整信息, gethostbyaddr函数根据IP地址获取主机的完整信息。 gethostbyname函数通常先在本地的 /etc/hsots配置的文件中查找主机, 如果没有找到,再去访问DNS服务器。

这两个函数定义如下:

1
2
3
4
#include <netdb.h>

struct hostent* gethostbyname(const char* name);
struct hostent* gethostbyaddr(const void* addr, size_t len, int type);

hostent结构体定义如下:

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

struct hostent
{
char* h_name; //主机名
char** h_aliases; //主机别名列表,可能有多个
int h_addrtype; //地址类型(地址族)
int h_length; //地址长度
char** h_addr_list;//按网络字节序列出的主机IP地址列表
};

2. getservbyname和getservbyport

getservbyname函数根据名称获取某个服务的完整信息, getsrvbyport函数根据端口号获取某个服务的完整信息。 他们实际上都是通过读取 /etc/services 文件来获取服务信息的。

1
2
3
4
#include <netdb.h>

struct servent* getservbyname(const char* name, const char* proto);
struct servent* getsrvbyport(int port, const char* proto);

name参数指定目标服务器的名字,port参数指定目标服务对应的端口号, proto参数指定服务类型。

结构体servent定义如下:

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

struct servent
{
char* s_name; //服务名称
char** s_aliases; //服务的别名列表,可能有多个
int s_port; //端口号
char* s_proto; //服务类型,通常是tcp或者udp
};

3. getaddrinfo

getaddrinfo函数既能通过主机名获取ip地址(内部使用gethostbyname)也能 通过服务名获得端口号(内部使用getservbyname)。

1
2
3
#include <netdb.h>

int getaddrinfo(const char* hostname, const char* service, const struct addrinfo* hints, struct addrinfo** result)

hostname参数可以接收主机名,也可以接收字符串表示的IP地址(IPv4用点分十进制 字符串,IPv6用十六进制字符串)。 同样,service参数可以接收服务名,也可以接收字符串表示的十进制端口号。 hints参数是应用程序给getaddrinfo的一个提示,一对getaddrinfo的输出进行更精确的控制。 result参数指向一个链表,该链表用于存储getaddrinfo反馈的结果。

  • getaddrinfo成功返回0,失败返回错误码

getaddrinfo反馈的每一条结果都是addrinfo结构体类型的对象, 结构体addrinfo定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <netdb.h>

struct addrinfo
{
int ai_flags; //
int ai_family;//地址族
int ai_socktype;//服务类型,SOCK_STREAM 或 SOCK_DGRAM
int ai_protocol;//
socklent_t ai_addrlen;// socket地址 ai_addr的长度
char* ai_canonname;//主机的别名
struct sockaddr* ai_addr; //指向socket地址
struct addrinfo* ai_next; //指向下一个sockinfo结构的对象
};
//使用 getaddrinfo 函数
struct addrinfo hints;
struct addrinfo* res;

bzero(&hints, sizeof(hints));
hints.ai_socktype = SOCK_STREAM;
getaddrinfo("ernest-laptop", "daytime", &hints, &res);

getaddrinfo将隐式地分配堆内存(可通过valgrind工具查看), 因为res指针原本没有指向一块合法内存的, 所以,getaddrinfo调用结束后,必须使用如下配对函数来释放这块内存:

1
2
3
#include <netdb.h>

void freeaddrinfo(struct addrinfo* res);

4. getnameinfo

getnameinfo函数能通过socket地址同时获得以字符串表示的主机名(内部使用gethostbyaddr函数)和服务名(内部使用getservbyport函数)。

1
2
3
#include <netdb.h>

int getnameinfo(const struct sockaddr* sockaddr, socklen_t addrlen, char* host, socklen_t hostlen, char* serv, socklen_t servlen, int flags);

getnameinfo将返回的主机名存储在host参数指向的缓存中, 将服务名存储在serv参数指向的缓存中, hostlen和servlen参数分别指定这两块缓存的长度。 flags参数控制getnameinfo的行为。

  • getnameinfo成功返回0,失败返回错误码

5. 错误码

Linux下strerror函数能将数值错误码errno转换成易读的字符串形式。 同样,下面的函数可将表5-8(getaddrinfo和getnameinfo的错误码)的错误码转换成其字符串形式:

1
2
3
#include <netdb.h>

const char* gai_strerror(int error);

关于EAGAIN 与 EWOULDBLOCK

EAGAIN的官方定义:

“Resource temporarily unavailable.” The call might work if you try again later. The macro EWOULDBLOCK is another name for EAGAIN; they are always the same in the GNU C Library.

翻译:资源短暂不可用,这个操作可能等下重试后可用。它的另一个名字叫做EWOULDAGAIN,这两个宏定义在GNU的c库中永远是同一个值。

EWOULDBLOCK的定义:

“Operation would block.” In the GNU C Library, this is another name for EAGAIN (above). The values are always the same, on every operating system.

翻译:操作将会被阻塞,在GNU C的库中,它的另外一个名字是EAGAIN,在任何操作系统中他们两个的值都是一样的。

这两个错误码在大多数系统下是都同一个东西,特别是在使用了GNU的libc库平台(目前广泛使用的centos和ubuntu都是)下一定是相同的。这个错误产生的情况:

  • 尝试在一个设置了非阻塞模式的对象上执行阻塞操作,重试这个操作可能会阻塞直到其他条件让它可读、可写或者其他操作。
  • 对某些操作来说,资源短暂不可用。例如fork函数可能返回这个错误(当没有足够的资源能够创建一个进程时),可以采取的操作是休息一段时间然后再继续操作。

高级IO函数


Linux提供了许多高级的IO函数,它们可能不像Linux常用的IO函数(如open和read)那么常用,但他们在特定条件下却能表现出优异的性能。 这些函数大致可以分为三类:

  • 用于创建文件描述符的函数,包括pipe,dup/dup2函数
  • 用于读写数据的函数,readv/writev,sendfile,mmap/munmap,splice,tee
  • 用于控制I/O行为和属性的函数,如fcntl

pipe函数

pipe函数可以用于创建一个管道,以实现进程间的通信

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

pipe函数的参数是含有两个int 整形的数组指针,成功时返回0 ,并将文件描述符值填写到数组中,fd[0]表示读端,fd[1]表示写,两端不可颠倒,默认情况这两个文件描述符都是阻塞的。如果当管道为空时,调用系统调用read()时,将被阻塞,直到管道内有数据可读。当管道为满时,调用write()也会被阻塞,直到管道内有足够的空闲空间。如果将fd[0] fd[1]设置为非阻塞,则read()和write()就会有不同的行为,关于阻塞与非阻塞,我们将在后续文章中进行讨论。

当fd[1]的引用计数减为0则表示,没有任何一个进行往管道中写入数据,则对于fd[0] 的read()操作返回值为0,表示读到末尾(EOF)。反之,当fd[0]的引用计数减为0,表示没有任何一个进程读这个管道,则fd[1]写端的write()将失败,引发SIGPIPE信号。管道内部传输的也是字节流,这点也和TCP传输有些区别,TCP连接写入的数据,取决于滑动窗口的大小,而管道有一个容量限制,默认大小为65536字节,可通过fcntl函数修改管道大小。

可通过socketpair创建双向管道

1
2
3
#include<sys/types.h>
#include<sys/socket.h>
int socketpair(int domain,int type, int protocol,int fd[2]);

前三个参数与socket函数中的参数意义相同,但是domain 只能使用AF_UNIX(表示Unix本地域协议族),因为我们仅能在本地使用这个双向管道,socketpair成功时返回0,失败返回-1并设置errno


dup函数与dup2函数

有时候我们希望把标准输入重定向到一个文件,或者标准输出重定向到一个网络连接(如CGI编程)

1
2
3
#include<unistd.h>
int dup(int file_descriptor);
int dup2(int file_descriptor_one,file_descriptor_two);

dup 创建的新文件描述符,和原来的文件描述符file_descriptor指向相同的文件,管道或者网络连接,但不继承原来文件描述符的属性(如close-on-exec和non-blocking等)。dup返回的文件描述符总是取系统当前可用的最小整数值。dup和dup2系统调用失败时返回-1并设置errno


readv /writev 函数

readv将文件描述符读到分散的内存块中,writev 将多块分散的内存中的数据写到一个文件描述符中,

1
2
3
#include<sys/uio.h>
ssize_t readv(int fd,const struct iovec* vector,int count);
ssize_t writev(int fd,const struct iovec* vector,int count);

fd 是目标文件描述符,vector 是类型为iovec类型的结构体,该结构描述了一款内存,count 表示vector数组的长度


sendfile函数

sendfile函数表示在两个文件描述符之间数据传递,完全在内核完成,避免了内核缓冲区到用户缓冲区的拷贝,效率很高,被称为零拷贝

1
2
#include<sys/sendfile.h>
ssize_t sendfile(int out_fd,int in_fd,off_t* offset,size_t count);

in_fd,表示待读出的文件描述符,out_fd ,表示待写入的文件描述符,offset表示读人的位置,NULL表示默认起始位置,count表示在两个文件描述符传递的字节数,in_fd 必须是一个真实的文件,out_fd必须是一个socket,所以sendfile专门为网络传输文件设计的。


mmap/munmap 函数

mmap 函数是申请了一块内存,我们将这块内存作为进程间通信的共享内存,也可以将文件映射到其中,munmap 释放申请的这块内存

1
2
3
#include<sys/mman.h>
void* mmap(void *start,siez_t length,int port,int flags, int fd,off_t offset);
int munmap(void *start,size_t length);

start 表示内存的起始地址,length表示内存段的长度,prot表示内存访问权限,flags 表示内存修改后控制程序的行为,fd 表示映射文件的描述符,offset表示文件映射的位置。


splice函数

用于两个文件描述符之间的数据交流,也是一个零拷贝

1
2
#include<fcntl.h>
ssize_t splice(int fd_in,loff_t* off_in,int fd_out,loff_t* off_out,size_t len,unsigned int flags);

splice 函数fd_in 或者 fd_out 必须有一个是管道文件描述符

fd_in参数:待读取数据的文件描述符。
off_in参数:指示从输入数据的何处开始读取,为NULL表示从当前位置。如果fd_in是一个管道描述符,则off_in必须为NULL。
fd_out参数:待写入数据的文件描述符。
off_out参数:同off_in,不过用于输出数据。
len参数:指定移动数据的长度。
flags参数:表示控制数据如何移动,可以为以下值的按位或:

  • SPLICE_F_MOVE:按整页内存移动数据,存在bug,自内核2.6.21后,实际上没有效果。
  • SPLICE_F_NONBLOCK:非阻塞splice操作,实际会受文件描述符本身阻塞状态影响。
  • SPLICE_F_MORE:提示内核:后续splice将调用更多数据。
  • SPLICE_F_GIFT:对splice没有效果。

返回值

返回值>0:表示移动的字节数。
返回0:表示没有数据可以移动,如果从管道中读,表示管道中没有被写入数据。
返回-1;表示失败,并设置errno。

fdin和fdout必须至少有一个是管道文件描述符。


tee 函数

tee 函数用于两个管道之间的数据交流,也是零拷贝操作

1
2
#include<fcntl.h>
ssize_t tee(int fd_in,int fd_out,size_t len,unsigned int flags);

fd_in 和fd_out 必须都是管道文件描述符。

参数意义

fdin参数:待读取数据的文件描述符。
fdout参数:待写入数据的文件描述符。
len参数:表示复制的数据的长度。
flags参数:同splice( )函数。

fdin和fdout必须都是管道文件描述符。

返回值

返回值>0:表示复制的字节数。
返回0:表示没有复制任何数据。
返回-1:表示失败,并设置errno。


fcntl函数

fcntl函数是对各种文件描述符进行操作,修改属性

1
2
#include<fcntl.h>
int fcntl(int fd,int cmd,...);

cmd参数:

img

1
2
3
4
5
6
7
8
9
10
void setnonblock(int fd)//设置非阻塞
{
int old_fcntl = fcntl(fd,F_GETFL);
int new_fcntl = old_fcntl | O_NONBLOCK;

if ( fcntl(fd,F_SETFL,new_fcntl) == -1 )
{
perror("fcntl error");
}
}

Linux 服务器程序规范


除了网络通信外,服务器程序还必须考虑许多其他细节问题,零碎,但基本上是模板式的。比如:

  • Linux服务器程序一般以后台形式运行。后台程序又称守护进程。它没有控制终端,因而也不会意外接受用户输入。守护进程的父进程一般是init进程(PID=1)
  • Linux服务器程序通常有一套日志系统,它至少能输出日志到文件,有的高级服务器可以输出日志到专门的UDP服务器。大部分后台进程都在/var/log下有自己的日志目录
  • Linux服务器程序一般以某个专门的非root身份运行。比如mysqld, httpd, syslogd等后台进程,并分别有自己的运行账户mysql, apache, syslog
  • Linux服务器通常时可配置的。服务器程序通常处理很多命令选项,如果一次运行的选项太多,则可以用配置文件来管理。绝大多数服务器程序都有配置文件并存放在/etc下
  • Linux服务器程序通常在启动时生成一个PID文件并存入/var/run目录中,以记录该后台进程的PID。
  • Linux服务器程序通常需要考虑系统资源和限制,以预测自身能承受多大负荷,比如进程可用文件描述符总数和内存总量等

守护进程


守护进程(daemon)是生存期长的一种进程,没有控制终端。它们常常在系统引导装入时启动,仅在系统关闭时才终止。UNIX系统有很多守护进程,守护进程程序的名称通常以字母“d”结尾:例如,syslogd 就是指管理系统日志的守护进程。通过ps进程查看器 ps -efj 的输出实例

守护进程是在后台运行不受终端控制的进程(如输入、输出等),一般的网络服务都是以守护进程的方式运行。守护进程脱离终端的主要原因有两点:

  1. 用来启动守护进程的终端在启动守护进程之后,需要执行其他任务。
  2. 如其他用户登录该终端后,以前的守护进程的错误信息不应出现)由终端上的一些键所产生的信号(如中断信号),不应对以前从该终端上启动的任何守护进程造成影响。要注意守护进程与后台运行程序(即加&启动的程序)的区别。

**怎样创建守护进程 ? **

在创建守护进程之前,需要了解一些基础概念:

进程组 :

  • 每个进程除了有一个进程ID之外,还属于一个进程组
  • 进程组是一个或多个进程的集合,同一进程组中的各进程接收来自同一终端的各种信号
  • 每个进程组有一个组长进程。组长进程的进程组ID等于其进程ID

会话:会话(session)是一个或多个进程组的集合,进程调用 setsid 函数(原型:pid_t setsid(void) )建立一个会话。

进程调用 setsid 函数建立一个新会话,如果调用此函数的进程不是一个进程组的组长,则此函数创建一个新会话。具体会发生以下3件事:

  • 该进程变成新会话的会话首进程(session leader,会话首进程是创建该会话的进程)。此时,该进程是新会话的唯一进程。
  • 该进程成为一个新进程组的组长进程。新进程组ID是该调用进程的进程ID
  • 该进程没有控制终端。如果调用setsid之前该进程有一个控制终端,那么这种联系也被切断

如果该调用进程已经是一个进程组的组长,则此函数返回出错。为了保证不处于这种情况,通常先调用fork,然后使其父进程终止,而子进程则继续。因为子进程继承了父进程的进程组ID,而其进程ID是重新分配的,两者不可能相等,这就保证了子进程不是一个进程组的组长。

对于守护进程,需要遵守一些编写规则:

  • 在后台运行:为避免挂起控制终端,将守护进程放入后台运行。方法亦即在进程中调用 fork 后使父进程终止,子进程则继续在后台运行
1
2
if ((pid = fork()) != 0) /* parent */
exit(0);
  • 脱离控制终端,登陆会话和进程组:调用 setsid 后会发生的3件事上面已经阐述:(a)成为新会话的首进程,(b)成为一个新进程组的组长进程、(c)没有控制终端
  • 禁止进程重新打开控制终端:进程已经成为无终端的会话组长。但它可以重新申请打开一个控制终端。可以通过使进程不再成为会话组长来禁止进程重新打开控制终
1
2
if ( (pid = fork()) != 0)/* parent */
exit(0);
  • 进程从创建它的父进程那里继承了打开的文件描述符。如不关闭,将会浪费系统资源,造成进程所在的文件系统无法卸下以及引起无法预料的错误
  • 当前目录更改为根目录:从父进程处继承过来的当前工作目录可能在一个挂载的文件系统,所以如果守护进程的当前工作目录在一个挂载文件中,那么该文件系统就不能被卸载
  • 关闭不再需要的文件描述符:这使守护进程不再持有从其父进程继承来的任何文件描述符

系统日志

服务器的调试和维护都需要一个专业的日志系统。Linux提供一个守护进程来处理系统日志:syslogd, 不过现在的Linux系统上使用的都是它的升级版:rsyslogd.

rsyslogd 守护进程既能接收用户进程输出的日志,又能接收内核日志。用户进程是通过调用syslog函数生成系统日志的。该函数将日志输出到一个UNIX本地域socket类型(AF_UNIX)的文件/dev/log中,rsyslogd 则监听该文件以获取用户进程的输出。

rsyslogd守护进程在接收到用户进程或内核输入的日志后,会把它们输出至某些特定的日志文件。默认情况下,调试信息会保存至/varlog/debug文件,普通信息保存至/var/log/messages文件,内核消息则保存至/var/log/kerm.log文件。不过,日志信息具体如何分发,可以在rsyslogd的配置文件中设置。rsyslogd 的主配置文件是/ete/rsyslog.conf。

应用程序使用syslog函数与rsyslogd守护进程通信。syslog 函数的定义如下:

1
2
#include <syslog.h>
void syslog( int priority, const char* message, ... );

该函数采用可变参数(第二个参数message和第三个参数……)来结构化输出。priority 参数是所谓的设施值与日志级别的按位或。设施值的默认值是LOG_USER。

下面这个函数可以改变syslog的默认输出方式,进一步结构化日志内容:

1
2
#include <syslog.h>
void openlog( const char* ident,int logopt, int facility );

ident参数指定的字符串将被添加到日志消息的日期和时间之后,它通常被设置为程序的名字。logopt 参数对后续syslog调用的行为进行配置。facility参数可用来修改syslog函数中的默认设施值。

日志的过滤也很重要。程序在开发阶段可能需要输出很多调试信息,而发布之后我们又需要将这些调试信息关闭。解决这个问题的方法并不是在程序发布之后删除调试代码(因为日后可能还需要用到),而是简单地设置日志掩码,使日志级别大于日志掩码的日志信息被系统忽略。下面这个函数用于设置syslog的日志掩码:

1
2
#include <syslog.h>
int setlogmask(int maskpri );

maskpri参数指定日志掩码值。该函数始终会成功,它返回调用进程先前的日志掩码值。最后,不要忘了使用如下函数关闭日志功能:

1
2
#include <sys1og.h>
void closelog();

系统资源的限制

Linux上运行的程序都会受到资源限制的影响,比如物理设备限制(CPU数量、内存数量等)、系统策略限制(CPU 时间等),以及具体实现的限制(比如文件名的最大长度)。Linux系统资源限制可以通过如下一对函数来读取和设置:

1
2
3
4
5
6
7
8
9
10
finclude <sys/resource.h>
int getrlimit( int resource, struct rlimit *rlim );
int setrlimit( int resource, const struct rlimit *rlim );

rlim参数是rlimit结构体类型的指针,rlimit 结构体的定义如下:
struct rlimit
{
rlim_t rlim_cur;
rlim_t rlim_max;
);

rlim_t是一个整数类型,它描述资源级别。rlim_cur成员指定资源的软限制rlim_max成员指定资源的硬限制。软限制是一个建议性的、最好不要超越的限制,如果超越的话,系运行的程序才能增加硬限制。

改变工作目录和根目录

获取进程当前工作目录和改变进程工作目录的函数分别是:

1
2
3
#include <unistd .h>
char* getcwd( char* buf, size_t size );
int chdir( const char* path );

buf参数指向的内存用于存储进程当前工作目录的绝对路径名,其大小由size参数指定。

getcwd 函数成功时返回一个指向目标存储区(buf 指向的缓存区或是getcwd在内部动态创建的缓存区)的指针,失败则返回NULL并设置errno.

chdir函数的path 参数指定要切换到的目标目录。它成功时返回0, 失败时返回-1并设置errno。

改变进程根目录的函数是chroot其定义如下:

1
2
#include <unistd. h>
int chroot( const char* path ) ;

path参数指定要切换到的目标根目录。它成功时返回0,失败时返回-1。但它并不改变当前进程的工作目录

服务器程序后台化

让一个进程以守护进程的方式运行。

以下是示例的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
//参考《APUE》
void
daemonize(const char *cmd)
{
int i, fd0, fd1, fd2;
pid_t pid;
struct rlimit rl;
struct sigaction sa;

/*
* Clear file creation mask.
*/
umask(0);

/*
* Get maximum number of file descriptors.
*/
if (getrlimit(RLIMIT_NOFILE, &rl) < 0)
err_quit("%s: can't get file limit", cmd);

/*
* Become a session leader to lose controlling TTY.
*/
if ((pid = fork()) < 0)
err_quit("%s: can't fork", cmd);
else if (pid != 0) /* parent */
exit(0);
setsid();

/*
* Ensure future opens won't allocate controlling TTYs.
*/
sa.sa_handler = SIG_IGN;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
if (sigaction(SIGHUP, &sa, NULL) < 0)
err_quit("%s: can't ignore SIGHUP", cmd);
if ((pid = fork()) < 0)
err_quit("%s: can't fork", cmd);
else if (pid != 0) /* parent */
exit(0);

/*
* Change the current working directory to the root so
* we won't prevent file systems from being unmounted.
*/
if (chdir("/") < 0)
err_quit("%s: can't change directory to /", cmd);

/*
* Close all open file descriptors.
*/
if (rl.rlim_max == RLIM_INFINITY)
rl.rlim_max = 1024;
for (i = 0; i < rl.rlim_max; i++)
close(i);

/*
* Attach file descriptors 0, 1, and 2 to /dev/null.
*/
fd0 = open("/dev/null", O_RDWR);
fd1 = dup(0);
fd2 = dup(0);

/*
* Initialize the log file.
*/
openlog(cmd, LOG_CONS, LOG_DAEMON);
if (fd0 != 0 || fd1 != 1 || fd2 != 2) {
syslog(LOG_ERR, "unexpected file descriptors %d %d %d",
fd0, fd1, fd2);
exit(1);
}
}

Linux 提供了完成同样功能的库函数:

1
2
#include <unistd. h>
int daemon( int nochdir, int noclose ) ;

其中,nochdir 参数用于指定是否改变工作目录,如果给它传递0, 则工作目录将被设置为“1”(根目录),否则继续使用当前工作目录。noclose 参数为0时,标准输入、标准输出和标准错误输出都被重定向到/dev/null文件,否则依然使用原来的设备。该函数成功时返回0,失败则返回-1并设置errno。

高性能服务器程序框架


我们按照服务器的一般原理,将服务器解构为如下三个模块:

  • I/O处理单元,我们将介绍I/O处理单元的四种I/O模型和两种高效事件处理方式

  • 逻辑单元,我们将介绍逻辑单元的两种高效并发模式,以及高效的逻辑处理方式——有限状态机

  • 存储单元


服务器模型

1. C/S模型

TCP/IP协议在设计和实现上并没有客户端和服务器的概念,在通信过程中所有机器都是对等的。但由于资源都被数据提供者所垄断,所以几乎所有的网络应用程序都很自然地采用了C/S模型:所有客户端通过访问服务器来获取所需的服务。

服务器启动后,首先创建一个或多个监听socket,并调用bind函数将其绑定到服务器感兴趣的端口上,然后调用listen函数等待客户端连接。服务器稳定运行之后,客户端就可以调用connect函数向服务器发起连接了。由于客户连接请求是随机到达的异步事件,服务器需要使用某种I/O模型来监听这一事件。I/O模型有多种。服务器使用的是I/O复用技术之一的select系统调用。当监听到连接请求后,服务器就调用accept函数接受它,并分配一个逻辑单元为新的连接服务。

在图8.2中,服务器给客户端分配的逻辑单元是由fork系统调用创建的子进程。逻辑单元读取客户请求,处理该请求,然后将处理结果返回给客户端。客户端接收到服务器反馈的结果之后,可以继续向服务器发送请求,也可以立即主动关闭连接。

2. P2P模型

P2P模型模型比C/S模型更符合网络通信的实际情况。它摒弃了以往以服务器为中心的格局,让网络上所有主机重新回归对等的地位。

P2P模型使得每台机器在消耗服务的同时也给别人提供服务,这样自愿能够充分、自由地共享。云计算机群可以看做P2P模型的典范。但P2P模型的缺点也很明显:当用户之间传输的请求过多时,网络的负载将加重。

实际使用的P2P模型通常带有一个专门的发现服务器,如图8-3所示。发现服务器通常还提供查找服务,使每个客户都能尽快找到自己需要的资源。

服务器编程框架

虽然服务器程序种类繁多,但其基本框架是相同的,不同之处在于逻辑处理

img

该图既能用来描述一台服务器,也能用来描述一个服务器集群

image-20220209140249229 image-20220209140300668

I/O处理单元是服务器管理客户连接的模块。它通常要完成以下工作:等待并接受新的客户端连接,接收客户数据,将服务器响应数据返回给客户端。但是,数据的收发不一定在I/O处理单元中执行,也可能在逻辑单元中执行,具体在何处执行取决于事件处理模式。对于一个服务器机群来说,I/O处理单元是一个专门的接入服务器。它实现负载均衡,从所有逻辑服务器中选取负荷最小的一台来为新客户服务。
一个逻辑单元通常是一个进程或线程。它分析并处理客户数据,然后将结果传递给I/O处理单元或者直接发送给客户端。对服务器机群而言,一个逻辑单元本身就是一台逻辑服务器。服务器通常拥有多个逻辑单元,以实现对多个客户任务的并行处理。

网络存储单元可以是数据库、缓存和文件,甚至是一台独立的服务器。但它不是必须的,比如ssh、telnet等登录服务就不需要这个单元。

请求队列是各单元之间的通信方式的抽象。I/O处理单元接收到客户请求时,需要以某种方式通知一个逻辑单元来处理该请求。同样,多个逻辑单元同时访问一个存储单元时,也需要采用某种机制协调处理竞态条件。请求队列通常被实现为池的一部分。对于服务器机群而言,请求队列是各台服务器之间预先建立的、静态的、永久的TCP连接。这种TCP连接能提高服务器之间交换数据的效率,因为它避免了动态建立TCP连接导致的额外的系统开销。

IO模型

同步IO与异步IO:

  • 阻塞I/O、I/O复用和信号驱动I/O都是同步I/O模型。因为在这三种I/O模型中,I/O的读写操作,都是在I/O事件发生之后,由应用程序完成的。而对异步I/O而言,用户可以直接对I/O执行读写操作,这些操作告诉内核用户读写缓冲区的位置,以及I/O操作完成之后内核通知应用程序的方式。

  • 异步I/O的读写操作总是立即返回,而不论I/O是否是阻塞的,因为真正的读写操作已经由内核接管。也就是说,同步I/O模型要求用户代码自行执行I/O操作(将数据从内核缓冲区读入用户缓冲区,或将数据从用户缓冲区写入内核缓冲区),而异步I/O机制则由内核来执行I/O操作(数据在内核缓冲区和用户缓冲区之间的移动是由内核在“后台”完成的)。可以这样认为,同步I/O向应用程序通知的是I/O就绪事件,而异步I/O向应用程序通知的是I/O完成事件。

image-20220209154248787

两种高效的事件处理方式

服务器程序通常需要处理三类事件:I/O事件、信号及定时事件。
同步I/O模型通常用于实现Reactor模式,异步I/O则用于实现Proactor模式。

1. Reactor

同步IO模型(以epoll_wait为例)实现的Reactor模式的工作流程:

  1. 主线程往epoll内核事件表中注册socket上的读就绪事件。

  2. 主线程调用epoll_wait等待socket上有数据可读。

  3. 当socket上有数据可读时,epoll_wait通知主线程。主线程则将socket可读事件放入请求队列。

  4. 睡眠在请求队列上的工作线程被唤醒,它从socket读取数据,并处理客户请求,然后往epoll内核事件表中注册该socket上的写就绪事件。

  5. 主线程调用epoll_wait等待socket可写。

  6. 当socket可写时,epoll_wait通知主线程。主线程将socket可写事件放入请求队列。

  7. 睡眠在请求队列上的某个工作线程被唤醒,它往socket上写入服务器处理客户请求的结果。

img

工作线程从请求队列中取出事件后,将根据事件的类型来决定如何处理它:对于可读事件,执行读数据和处理请求的操作;对于可写事件,执行写数据的操作。因此,在Reactor模式中,没必要区分所谓的“读工作线程”和“写工作线程”。

2. Proactor

与Reactor模式不同,Proactor模式将所有I/O操作都交给主线程和内核来处理 (关键之处),工作线程仅仅负责业务逻辑。
使用异步I/O模型(以aio_read和aio_write为例)实现的Proactor模式的工作流程是:

  1. 主线程调用aio_read函数向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序。(以信号为例)
  2. 主线程继续处理其他逻辑。
  3. 当socket上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用。
  4. 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理客户请求之后,调用aio_write函数向内核注册socket上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序。(以信号为例)
  5. 主线程继续处理其他逻辑。
  6. 当用户缓冲区的数据被写入socket之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕。
  7. 应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭socket。

image-20220209164003091

连接socket上的读写事件是通过aio_read/aio_write向内核注册的,因此内核将通过信号来向应用程序报告连接socket上的读写事件。所以,主线程中的epoll_wait调用仅能检测监听socket上的连接请求事件,而不能用来检测连接socket上的读写事件。

两种高效的并发模式

1. 半同步/半异步模式

这里的“同步”和“异步”和前面的IO的“同步”“异步”是完全不同的概念。在IO模型中,“同步”和“异步”区分的是内核向应用程序通知的是何种IO事件(是就绪事件还是完成事件),以及该由谁来完成IO读写(是应用程序还是内核)。

在并发模式中,“同步”指的是程序完全按照代码序列的顺序执行;“异步”指的是程序的执行需要由系统事件来驱动。常见的系统事件包括中断、信号等。

img

  • 异步线程的执行效率高,实时性强,是很多嵌入式系统采用的模型。但编写异步方式执行的程序相对复杂,难于调试和扩展,而且不适合于大量的并发。
  • 同步线程则相反,它虽然效率相对较低,实时性较差,但逻辑简单。

在这种模式中,同步线程用于处理客户逻辑,充当逻辑单元,异步线程用于处理IO事件,相当于IO处理单元。异步线程监听到客户请求后,就将其封装为请求对象插入到请求队列中。 请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。具体选择哪个工作线程,这要看请求队列的实现方式。

半同步/半异步工作模式流程:

image-20220209171510849

半同步/半反应堆模式

image-20220209172301750

异步线程只有一个,由主线程充当。它负责监听所有socket上的事件。如果监听socket上有可读事件发生,即有新的连接请求到来,主线程就接受之以得到新的连接socket,然后往epoll内核事件表中注册该socket上的读写事件。如果连接socket上有读写事件发生,即有新的客户请求到来或有数据要发送至客户端,主线程就将该连接socket插入请求队列中。所有工作线程都睡眠在请求队列上,当有任务到来时,它们将通过竞争(比如申请互斥锁)获得任务的接管权。这种竞争机制使得只有空闲的工作线程才有机会来处理新任务,这是很合理的。这种模式中,他要求工作线程自己从socket上读取客户请求和往socket写入服务器应答。

缺点:

  1. 主线程和工作线程共享请求队列,主线程往请求队列添加任务和工作线程从请求队列取出任务都需要给队列加锁

  2. 每个工作线程同一时间只能处理一个客户请求,工作线程的切换将耗费大量CPU时间

高效的半同步/半反应堆模式

它的每个所有线程都是异步线程。主线程只管监听socket,连接socket由工作线程来管理。当有新的连接到来时,主线程就接受之并将新返回的连接socket派发给某个工作线程,此后该新socket上的任何I/O操作都由被选中的工作线程来处理,直到客户关闭连接。主线程向工作线程派发socket的最简单的方式,是往它和工作线程之间的管道里写数据,工作线程检测到管道有数据可读时,就分析是否是一个新的客户连接请求到来。如果是,则把该新socket上的读写事件注册到自己的epoll内核事件表中。

每个线程(主线程和工作线程)都维持自己的事件循环,它们各自独立地监听不同的事件。因此,在这种高效的半同步/半异步模式中,每个线程都工作在异步模式

image-20220209172845562

2. 主/从模式

领导者/追随者模式是多个工作线程轮流获得事件源集合,轮流监听、分发并处理事件的一种模式。在任何时间点,程序都仅有一个领导者线程,它负责监听I/O事件。而其他线程则都是追随者,它们休眠在线程池中等待成为新的领导者。当前的领导者如果检测到I/O事件,首先要从线程池中推选出新的领导者线程,然后处理I/O事件。此时,新的领导者等待新的I/O事件,而原来的领导者则处理I/O事件,二者实现了并发。
image-20220210003942857

缺点是只支持一个事件源集合

有限状态机

它是逻辑单元内部的一种高效编程方法

提高服务器性能的其他建议

服务器硬件资源相对“充裕”,那么提高服务器性能的一个很直接的方法就是以空间换时间,即“浪费”服务器的硬件资源,以换取其运行效率。这就是池(pool)的概念。池是一种资源的集合,这组资源在服务器启动之初就被完全创建并初始化,这称为静态资源分配。速度要快得多,因为分配系统资源的系统调用都是很耗时的。当服务器处理完一个客户连接后,可以把相关的资源放回池中,无须执行系统调用来释放资源。从最终效果来看,池相当于服务器管理系统资源的应用层设施,它避免了服务器对内核的频繁访问。

按照资源类型分类:
内存池:通常用于socket的接收缓存和发送缓存。
进程池、线程池:并发编程常用“伎俩”。
连接池:常用于服务器或服务器集群的内部永久连接。

  1. 数据复制(避免不必要的数据复制)

应该避免不必要的数据复制,尤其当数据复制发生在用户代码和内核之间的时候。如果内核可以直接处理从socket或者文件读入的数据,则应用程序就没有必要将这些数据从内核缓冲区复制到应用程序缓冲区。如ftp服务器,服务器只需检测目标文件是否存在,以及客户是否有读取权限,而不用关心文件具体内容。就可以使用“零拷贝”sendfile来直接将其发送给客户。

此外,用户代码内部(不访问内核)的数据复制也是应该避免的。如两个工作进程之间要传递大量的数据时,我们就应该考虑使用共享内存来在它们之间直接共享这些数据,而不是使用管道或者消息队列来传递

  1. 上下文切换和锁

并发程序必须考虑上下文切换(context switch)的问题,即进程线程切换导致的系统开销。即使是IO密集型的服务器,也不应该使用过多的工作线程(或进程,下同),否则切换将占用大量CPU时间,服务器真正用于业务逻辑的CPU时间比重就显得不足了。因此为每个客户连接都建立一个服务器线程的模型不可取。之前描述的半同步半异步模型是一个比较合理的解决方案,它允许一个线程同时处理多个客户连接。此外,多线程服务器的一个优点是不同的线程可以同时运行在不同的cpu上。当线程数量不大于cpu的数目时,上下文切换就不是问题了。

并发程序需要考虑的另一个问题是共享资源的加锁保护。锁通常被认为是导致服务器效率低下的一个因素,因为由它引入的代码不仅不处理任何业务逻辑,而且需要访问内核资源。因此,服务器如果有更好的解决方案,就应该避免使用锁。如果服务器必须使用锁,则可以考虑减小锁的粒度,比如使用读写锁。当所有工作线程都只读取一块共享内存的内容时,读写锁并不会增加系统的额外开销。只有当其中一个工作线程需要写这块内存时,系统才必须去锁住这块区域。

IO复用


迅速看原理,勇敢看源码

select系统调用

作用:

在一段指定时间内,监听用户感兴趣的文件描述符的可读、可写和异常等事件。

原型:

1
2
#include <sys/select.h>
int select ( int nfds, fd_set* readfds, fde_set* writefds, fd_set* exceptfds, struct timeval* timeout );

函数说明:

  • nfds: 指定被监听的文件描述符的总数,通常为所有文件描述符中的最大值+1
  • readfds、writefds 、exceptfds: 可读、可写和异常等事件对应的文件描述符集合。
  • fd_set结构:仅包含一个整型数组,该数组的每个元素的每一位标记了一个文件描述符。fd_set能容纳的文件描述符数量由FD_SETSIZE指定,这就限制了select能同时处理的文件描述符的总量。

select中的fd_set集合容量的限制为FD_SETSIZE,一般为1024 。修改它,需要重新编译内核。

fd_set相关的位操作:

1
2
3
4
5
6
7
8
9
10
11
12
#include <sys/select.h>
FD_ZERO( fd_set *fdset );
FD_SET( int fd, fd_set *fdset );
FD_CLR( int fd, fd_set *fdset );
int FD_ISSET( int fd, fd_set *fdset );
/*对于fd_set类型通过上面四个宏来操作:
FD_ZERO(fd_set *fdset) 将指定的文件描述符集清空,在对文件描述符集合进行设置前,必须对其进行初始化,如果不清空,由于在系统分配内存空间后,通常并不作清空处理,所以结果是不可知的。
FD_SET(int fd,fd_set *fdset) 用于在文件描述符集合中增加一个新的文件描述符。
FD_CLR(int fd, fd_set *fdset) 用于在文件描述符集合中删除一个文件描述符。
FD_ISSET(int fd,fd_set *fdset) 用于测试指定的文件描述符是否在该集合中。

先调用宏FD_ZERO将指定的fd_set清零,然后调用宏FD_SET将需要测试的fd加入fd_set,接着调用函数select测试fd_set中的所有fd,最后用宏FD_ISSET检查某个fd在函数select调用后,相应位是否仍然为1。*/
  • timeout:设置select的超时时间。这是timeval结构指针,用来告诉内核select等待多久。不过我们不能完全信任select调用返回后的timeout值,比如调用失败时,timeout值是不确定的。timeval结构体如下:
1
2
3
4
5
struct timeval
{
long tv_sec; \\秒数
long tv_usec; \\微秒
}

​ 如果给timeout变量的tv_sec成员和tv_usec成员都传递0,则select将立即返回。如果给timeout传递NULL,则select将一直阻塞,直到某个文件描述符就绪。

返回状态:

  • select成功时返回就绪(可读、可写和异常)文件描述符的总数。
  • 如果在超时时间内没有任何文件描述符就绪,select将返回0。
  • select失败时返回-1并设置errno。
  • 如果select 等待期间,程序接收到信号,则select立即返回-1,并设置errno为EINTR。

文件描述符就绪条件

可读:

  • socket内核接收缓冲区中的字节数大于或等于其低水位标记SO_RCVLOWAT。此时我们可以无阻塞地对该socket,并且读操作返回的字节数大于0。
  • socket通信的对方关闭连接,此时读操作返回0。
  • 监听socket上有新的连接请求。
  • socekt上有未处理的错误,此时我们可以使用getsockopt来读取和清除该错误。

可写:

  • socket内核发送缓冲区中的可用字节数大于或等于其低水位标记SO_SNDLOWAT。此时我们可以无阻塞地写该socket,并且写操作返回的字节数大于0。
  • socket的写操作被关闭。对写操作被关闭的socket执行写操作将出发一个SIGPIPE信号。
  • socket使用非阻塞connect连接成功或者失败之后。
  • socket上有未处理的错误,此时我们可以使用getsockopt来读取和清除该错误。

异常:

  • socket上接收到带外数据。

处理带外数据

socket上接收到普通数据和带外数据都将使select返回,但socket处于不同的就绪状态:前者处于可读状态,后者处于异常状态。

poll系统调用

poll系统调用

作用:和select类型,也是在指定时间内轮询一定数量的文件描述符,以测试其中是否有就绪者。

原型:

1
2
#include <poll.h>
int poll ( struct pollfd* fds, nfds_t nfds, int timeout );

函数说明:

  • fds:一个pollfd结构类型的数组,指定我们所感兴趣的文件描述符上发生的可读,可写和异常事件。

pollfd结构:

1
2
3
4
5
6
struct pollfd
{
int fd; /* 文件描述符 */
short events; /* 注册的事件 */
short revents; /* 实际发生的事件,由内核填充 */
}

其中,fd成员指定文件描述符;events 成员告诉poll监听fd上的哪些事件,它是一系列事件的按位或;revents成员则由内核修改,以通知应用程序fd上实际发生了哪些事件。 poll 支持的事件类型如下:

img

image-20220211110816948

  • nfds :指定被监听事件集合fds的大小。其类型nfds_t 的定义如下:
1
typedef unsigned long int nfds_t;
  • timeout :指定poll的超时值,单位是毫秒。当timeout 为-1时,poll调用将永远阻塞,直到某个事件发生;当timeout为0时,poll调用将立即返回。

poll系统调用轮询描述符的数量的限制:一个进程所能打开的最大文件描述符有关。可以通过调整内核参数、ulimit -n命令、setrlimit函数。

  • 一个系统所能打开的文件描述符的最大数也是有限制的,跟内存有关,可以通过 /proc/sys/fs/file-max 调整。
  • 一个进程所能打开的文件描述符最大值,可以通过调整内核参数、ulimit -n命令、setrlimit函数。

epoll

特点:

epoll是linux上特有的IO复用函数,它在实现上和使用上与select有很大的差异。首先epoll使用一组函数完成任务,而不是单个函数,其次, epoll把用户关心的文件描述符上的事件放在内核里的一个事件表中,从而无须像select和poll那样每次调用都要重复传入文件描述符集或事件集。但需要一个额外的文件描述来唯一标识内核中的这个事件表。

这个事件表的文件描述符由如下函数来创建:

1
2
#include <sys/epoll.h>
int epoll_create ( int size );

size参数现在不起作用,只是给内核一个提示,告诉他事件表需要多大,该函数返回的文件描述符将用作其他所有epoll系统调用的第一个参数,以指定要访问的事件表

操作内核事件表:

1
2
#include <sys/epoll.h>
int epoll_ctl ( int epfd, int op, int fd, struct epoll_event *event );

函数说明:

  • fd:要操作的文件描述符
  • op:指定操作类型
  • epfd:内核事件表
  • event:指定事件,它是epoll_event结构指针类型,

epoll_event定义:

1
2
3
4
5
struct epoll_event
{
__unit32_t events; /* epoll事件 */
epoll_data_t data; /* 用户数据 */
};

结构体说明:

events:描述事件类型,和poll支持的事件类型基本相同(两个额外的事件:EPOLLET和EPOLLONESHOT,高效运作的关键)

data成员:存储用户数据

1
2
3
4
5
6
7
typedef union epoll_data
{
void* ptr; /* 指定与fd相关的用户数据 */
int fd; /* 指定事件所从属的目标文件描述符 */
uint32_t u32;
uint64_t u64;
} epoll_data_t;

op参数指定操作类型:

  • EPOLL_CTL_ADD:往事件表中注册fd上的事件
  • EPOLL_CTL_MOD:修改fd上的注册事件
  • EPOLL_CTL_DEL:删除fd上的注册事件

epoll_wait函数

主要接口

作用:在一段超时时间内,等待一组文件描述符上的事件

原型:

1
2
#include <sys/epoll.h>
int epoll_wait ( int epfd, struct epoll_event* events, int maxevents, int timeout );

函数说明:

返回:成功时返回就绪的文件描述符的个数,失败时返回-1并设置errno

参数:

  • timeout:与poll相同

  • maxevents:指定最多监听多少个事件

  • events:检测到事件,将所有就绪的事件从内核事件表(由第一个参数epfd来指定)中复制到它的第二个参数events指向的数组中。

    这个数组只用于输出检测到的就绪事件。而不像select与poll中的数组既用于传入用户注册的事件,又用于输出内核输出的就绪事件。

    所以效率较高。

LT和ET模式

对文件操作符的操作模式:

  • LT:电平触发,默认的工作模式。当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。这样,当应用程序下一次调用epoll_wait时,epoll_wait还会再次向应用程序通告此事件,直到该事件被处理。
  • ET:边沿触发,高效工作模式。文件描述符注册为EPOLLET事件,当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因为后续的epoll_wait调用将不再向应用程序通知这一事件。

区别:ET模式在很大程度上降低了同一个epoll事件被重复出发的参数,因此效率要比LT模式高。

注意:ET 模式要求socket为非阻塞模式,如果是阻塞的,那么读或写操作将会因为没有后续事件而一直处于阻塞状态(饥渴状态)。LT模式可以是阻塞或者非阻塞。

LT与ET模式的实例:

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
/*************************************************************************
> File Name: 9-3.cpp
> Created Time: Sat 03 Feb 2018 10:35:56 PM PST
************************************************************************/

#include"head.h"

using namespace std;

#define MAX_EVENT_NUMBER 1024
#define BUFFER_SIZE 10

//设置文件描述符为非阻塞模式
int setnonblocking(int fd) {
int old_option = fcntl(fd, F_GETFL);
int new_option = old_option | O_NONBLOCK;
fcntl(fd, F_SETFL, new_option);
return old_option;
}

//以两种不同模式将事件注册到epoll中
void addfd(int epollfd, int fd, bool enable_et) {
epoll_event event;
event.data.fd = fd;
event.events = EPOLLIN;
if(enable_et) event.events |= EPOLLET;
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
setnonblocking(fd);
}

void lt(epoll_event* events, int number, int epollfd, int listenfd) {
char buf[BUFFER_SIZE];
for(int i = 0; i < number; i ++) {
int sockfd = events[i].data.fd;
/*
此处的作用是?
*/
if(sockfd == listenfd) {
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
int connfd = accept(listenfd, (struct sockaddr*)&client_address, &client_addrlength);
addfd(epollfd, connfd, false);
}

else if(events[i].events & EPOLLIN) {
printf("event trigger once\n");
memset(buf, 0, sizeof(buf));
int ret = recv(sockfd, buf, BUFFER_SIZE, 0);
if(ret <= 0) {
close(sockfd);
continue;
}
printf("get %d bytes of content: %s\n", ret, buf);
}
else printf("something else happened\n");
}
}

void et(epoll_event* events, int number, int epollfd, int listenfd) {
char buf[BUFFER_SIZE];
for(int i = 0; i < number; i ++) {
int sockfd = events[i].data.fd;
if(sockfd == listenfd) {
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
int connfd = accept(listenfd, (struct sockaddr*)&client_address, &client_addrlength);

addfd(epollfd, connfd, true);
}
else if(events[i].events & EPOLLIN) {
//这段代码不会被重复触发,所以我们循环读取
printf("event trigger once\n");
while(1) {
memset(buf, 0, sizeof(buf));
int ret = recv(sockfd, buf, BUFFER_SIZE, 0);
if(ret < 0) {
//非阻塞模式的I/O,当下面的条件成立表示数据已经全部取走
if((errno == EAGAIN) || (errno == EWOULDBLOCK)) {
printf("read later\n");
break;
}
close(sockfd);
break;
}
else if(ret == 0) close(sockfd);
else printf("get %d bytes of content: %s\n", ret, buf);
}
}
else printf("something else happened\n");
}
}

int main(int argc, char** argv) {
if(argc <= 2) {
printf("usage: %s ip_address port_number\n", basename(argv[0]));
return 1;
}

const char* ip = argv[1];
int port = atoi(argv[2]);

int ret = 0;
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
address.sin_port = htons(port);
inet_pton(AF_INET, ip, &address.sin_addr);

int listenfd = socket(AF_INET, SOCK_STREAM, 0);
assert(listenfd >= 0);

ret = bind(listenfd, (struct sockaddr*)&address, sizeof(address));
assert(ret != -1);

ret = listen(listenfd, 5);
assert(ret != -1);

epoll_event events[MAX_EVENT_NUMBER];
int epollfd = epoll_create(5);
assert(epollfd != -1);

addfd(epollfd, listenfd, true);//设置触发方式

while(1) {
int ret = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
if(ret < 0) {
printf("epoll failure\n");
break;
}
//lt(events, ret, epollfd, listenfd);
et(events, ret, epollfd, listenfd);
}
close(listenfd);
return 0;
}

测试结果:

ET模式:image-20220210224210698

LT模式:image-20220210224237886

三组I/O复用函数的比较

select和poll都只能工作在相对低效的LT模式,而epoll则可以工作在ET高效模式,并且epoll还支持EPOLLONESHOT事件。该事件能进一步减少可读、可写和异常等事件被触发的次数。

从实现原理上来说,select和poll采用的都是轮询的方式,即每次调用都要扫描整个注册文件描述符集合,并将其中就绪的文件描述符返回给用户程序,因此它们检测就绪事件的算法的时间复杂度是O(n)。epoll则不同,它采用的是回调的方式。内核检测到就绪的文件描述符时,将触发回调函数,回调函数就该文件描述符上对应的事件插入就绪事件队列。内核最后在适当的时机将该就绪事件队列中的内容拷贝到用户空间。因此epoll_wait无须轮询整个文件描述符集合来检测哪些事件已经就绪,其算法时间复杂度是O(1)。

系统调用 select poll epoll
事件集合 用户通过3个参数分别传入感兴趣的可读、可写及异常等事件,内核通过对这些参数的在线修改来反馈其中的就绪事件。这使得用户每次调用select都要重置这3个参数 统一处理所有事件类型,因此只需要一个事件集参数。用户通过pollfd.events传入感兴趣的事件,内核通过修改pollfd.revents反馈其中就绪的事件 内核通过一个事件表直接管理用户感兴趣的所有事件。因此每次调用epoll_wait时,无需反复传入用户感兴趣的事件。epoll_wait系统调用的参数events仅用来反馈就绪的事件
应用程序索引就绪文件描述符的时 间复杂度 O(n) O(n) O(1)
最大支持文件描述符数 一般有最大值限制(FD_SETSIZE 为1024,修改后需重新编译内核) 65535(一个进程所能打开的最大文件描述符数量,ulimit -n或者setrlimit函数 65535(系统能打开的最大文件描述符数量,/proc/sys/fs/file-max
工作模式 LT LT 支持ET高效模式
内核实现和工作效率 采用轮询方式检测就绪事件时间复杂度:O(n) 采用轮询方式检测就绪事件时间复杂度:O(n) 采用回调方式检测就绪事件事件复杂度:O(1)

​ 对于poll函数,内核每次修改的是pollfd结构体的revents成员,而events成员保持不变,因此下次调用poll时应用程序无须重置pollfd类型的事件集参数。由于每次select和poll调用都返回整个用户注册的事件集合(其中包括就绪的和未就绪的的),所以应用程序索引就绪文件描述符的时间复杂度为O(n)。epoll则采用与select和poll完全不同的方式来管理用户注册的事件。它在内核中维护一个事件表,并提供了一个独立的系统调用epoll_ctl来控制往其中添加、删除、修改事件。这样每次epoll_wait调用都直接从该内核事件表中取得用户注册的事件,而无须反复从用户空间读入这些事件。epoll_wait系统调用的events参数仅用来返回就绪的事件,这使得应用程序索引的就绪文件描述符的时间复杂度达到0(1)。

需要说明的是:

​ epoll的效率未必一定比select和poll高。当活动连接比较多的时候,epoll_wait的效率未必比select和poll高,因为此时回调函数被触发的过于频繁。所以,epoll_wait适用于连接数量多,但活动连接较少的情况。

非阻塞式connect

聊天室程序

同时处理TCP与UDP服务

IO多路复用函数机制详谈:


参考文献:

IO多路复用机制详解 - Yeang - 博客园 (cnblogs.com)

epoll 机制详谈:


img

1
2
3
4
5
6
7
8
9
10
struct eventpoll
{
spin_lock_t lock; //对本数据结构的访问
struct mutex mtx; //防止使用时被删除
wait_queue_head_t wq; //sys_epoll_wait() 使用的等待队列
wait_queue_head_t poll_wait; //file->poll()使用的等待队列
struct list_head rdllist; //事件满足条件的链表
struct rb_root rbr; //用于管理所有fd的红黑树
struct epitem *ovflist; //将事件到达的fd进行链接起来发送至用户空间
}
1
2
3
4
5
6
7
8
9
10
11
12
struct epitem
{
struct rb_node rbn; //用于主结构管理的红黑树
struct list_head rdllink; //事件就绪队列
struct epitem *next; //用于主结构体中的链表
struct epoll_filefd ffd; //每个fd生成的一个结构
int nwait;
struct list_head pwqlist; //poll等待队列
struct eventpoll *ep; //该项属于哪个主结构体
struct list_head fllink; //链接fd对应的file链表
struct epoll_event event; //注册的感兴趣的事件,也就是用户空间的epoll_event
}

参考文献:

epoll内核源码详解+自己总结的流程_技术交流_牛客网 (nowcoder.com)

(163条消息) Epoll原理解析_~~ LINUX ~~-CSDN博客_epoll

共享内存技术mmap:


阻塞IO,非阻塞IO,IO多路复用,信号驱动 IO,异步IO