Linux高性能服务器编程
1-4章又介绍了TCP/IP协议
TCP/IP协议簇

上面这图很具有误导性,比如ICMP是作为数据封装到IP内,但把他们画到同一层。ARP是作为数据封装到数据链路(比如以太网帧)内,但把他们画到同一层
TCP: 可靠的、面向连接的、基于流的。
UDP: 不可靠、无连接、基于数据报的。
封装&分用:分用指的解析本层书籍,将处理后的数据往上层传。

复位报文段
产生复位报文段的三种情况:
访问不存在的端口
访问不存在的端口或者该端口处于TIME_WAIT状态,客户端会收到复位报文段。
异常终止连接
TCP提供了异常终止一个连接的方式,即向对方发送一个复位报文段。对方收到后,会丢弃所有排队等待发送的数据。
处理半打开连接
比如服务端和客户端已经连接上,但是服务端网线被拔了,重新接上后。只有客户端维护着连接,当他往管道写入数据时,会受到一个复位报文段。
TCP交互数据流
TCP连接的应用数据分为两类:交互数据和成块数据。
比如通telnet
访问服务器,会经过如下过程
每键入一个字符,会将该字符用TCP
包发给服务器,服务器回发了确认消息以及数据,客户端立刻回发确认消息。
这可能导致网络上过多的数据包,可以通过Nagle算法解决:即限制通信双方只能有一个未被确认的的报文段(即,收到对方的确认前不能发送新数据)
TCP成块数据流
带外数据
用于通告对方本端发生的重要事件。
TCP、UDP都没有真正的带外数据,不过TCP利用其头部中的紧急指针和紧急指针标志为应用程序提供了一种紧急方式。
发送方将带外数据写入到发送缓冲区后,会设置紧急指针和紧急指针标志。

TCP/IP通信案例
Linux网络编程基础API
socket地址API
主机字节序和网络字节序
大部分PC使用(小端)主机字节序,Java虚拟机使用大端字节序。为了避免在建立连接时再沟通双方字节序,所以干脆在传输过程中都使用大端(网络)字节序。
htonl, htons, ntohl, ntohs
是常用的转换方式。
通用socket地址
表示socket地址的是结构体sockaddr,
1 | struct sockaddr{ |
专用socket地址

所有专用socket地址类型的变量在使用时需要转化为通用socket地址类型sockaddr(强制转换)。
IP地址转换函数

inet_addr
用于将点分十进制字符串IPv4地址转换为字节序整数表示的IPv4地址。
inet_aton
也是完成这个功能,但他将转换结果保存到参数inp指向的结构中。
inet_ntoa
将网络字节序整数转化为点分十进制表示的IPv4地址,但该函数内部用一个静态变量存储转换结果,他是不可重入的。
上面的三个函数只能用于IPv4,下面的两个函数可以指定协议簇,适用于IPv4和IPv6.

创建socket
int socket(int domain, int type, int protocol)
。
- domain参数告诉系统使用哪个底层协议簇,比如PF_INET, PF_INET6
- type参数指定了服务类型,SOCK_STREAM表示使用TCP。(从内核2.6.17开始,可以将SOCK_STREAM和两个标志相与后传给函数,这两个标志SOCK_NONBLOCK和SOCK_CLOEXEC分别表示socket是非阻塞的以及创建子进程时在子进程中关闭该socket)
- protocol参数通常设为0
成功返回socket文件描述符,失败返回-1并设置errno。
命名socket
创建socket后,还需要为其指定某个具体的socket地址,这称为socket命名。int bind(int sockfd, const struct sockaddr* my_addr, socklen_t addrlen)
bind将my_addr指定的sockat地址分配给未命名的sockfd文件描述符。成功返回0,失败返回-1并设置errno,常见的两个errno:
- EACCES,如果绑定的地址是知名服务端口,bind将返回这个错误。
- EADDRINUSE,被绑定的地址正在使用中,比如将socket绑定到一个TIME_WAIT状态的socket地址。
监听socket
int listen(int sockfd, int backlog)
,
backlog参数指定处于ESTABLISHED
的socket的上限,典型值为5。处于半连接状态SYN_RCVD
的上限由内核参数配置。
接受连接
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen)
,
addr用来获取远端socket地址。accept成功时返回新的连接socket,否则返回-1并设置errno。
发起连接
int connect(int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen)
,
关闭连接
int close(int fd)
它会将该fd的引用计数减一,当fd的引用计数变为0时,才会关闭连接。如果要立刻终止连接,可以使用shutdown系统调用
int shutdown(int sockfd, int howto)
howto有三种参数
- SHUT_RD,关闭读,应用程序不能读该sockfd,并清空读缓冲区。
- SHUT_WR,关闭写,应用程序不能写该sockfd,并清空写缓冲区。
- SHUTRDWR,关闭读写
数据读写
TCP数据读写
通用的文件读写操作read
和write
适用于socket
,但socket编程接口提供了几个专门用于数据读写的数据调用,他们增强了对数据读写的控制,如:
1 | ssize_t recv(int sockfd, void *buf, size_t len, int flags); |
- recv: flags通常设为0即可。recv成功返回数据长度,出错则返回-1并设置errno,对方已经关闭连接则返回0。(如果还没收到对方的数据,则不返回)
- send,成功返回实际写入数据的长度,失败返回-1并设置errno。
flags参数提供了额外的控制。

flags参数只对当此send和recv调用生效,可以通过setsockopt来永久的修改socket的某些属性。
针对含有带外数据TCP数据读取,正常数据的接收会被带外数据截断,比如服务器发送123abc123
,其中abc是带外数据(设置flags为MSG_OOB),客户端通过一次recv是没法读取全部数据的,通过recv()读取123ab
,然后通过recv(MSG_OOB)读取c
, 通过recv读取123
。
UDP数据读写
1 | ssize_t recvfrom(int sockfd, void* buf, size_t len, int flags, struct sockaddr* src_addr, socklen_t* addrlen); |
因为UDP是无连接的,并不会将sockfd绑定到一个固定的地址上,所以每次发送数据时都需要指定从哪个地址发送数据,
通用数据读写函数
socket编程接口提供了一对通用的数据读写系统调用。它可以用与TCP和UDP的数据读取。

带外标记
地址信息函数
获取sockfd对应的socket地址。

socket选项
如果说fcntl是控制文件描述符属性的通用POSIX方法,那么如下的两个系统调用则是设置socket文件描述符属性的方法:

不同的level指定了要对哪些协议进行操作:

- 对服务器而言有些选项只能在listen系统调用前生效,因为比如最大报文段选项只能在TCP同步报文段设置,执行listen系统调用后,可能已经接受了客户端的连接,这时已经没法更改最大报文段选项了。通常accept返回的socket会自动继承监听socket的选项,包括SO_DEBUG、SO_DONTROUTE等。
- 对于客户端而言,这些选项应该在connect之前设置。
SO_REUSEADDR选项
通过将该选项设置为1,可以直接使用处于TIME_WAIT状态的socket地址,也可以通过修改内核参数来快速回收被关闭的socket。
SO_RCVBUF和SO_SNDBUF
设置TCP发送和接收缓冲区的大小。系统会将该参数加倍,并且不得小于某个最小值。
SO_REVLOWAT和SO_SNDLOWAT选项
通常低水位标记都为1字节。当可读数据大于低水位标记时,会通知应用程序读数据,当空闲空间大于低水位时,会通知往socket写数据。
SO_LINGER选项
用于控制close系统调用在关闭TCP连接时的行为。
默认情况下,当通过close关闭连接时,会立刻返回,并将TCP发送缓冲区残留的数据发送给对方。
当设置了SO_LINGER选项时,需要传递一个linger结构体
1
2
3
4struct linger{
int l_onoff; //开启还是关闭
int l_linger; //滞留时间
}- l_onoff等于0时,SO_LINGER选项不起作用,close仍然使用默认行为来关闭socket。
- l_onoff不为0,l_linger等于0,这时close系统调用立刻返回,TCP模块将丢弃残留的数据,同时给对方发送一个复位报文段。
- l_onoff不为0,l_linger大于0。
- 对于阻塞socket,close将等待一段长为l_linger的时间,直到发送完所有数据并收到确认,没有发送完以及得到确认会返回-1设置errno为EWOULDBLOCK.
- 对于非阻塞socket,close会立刻返回,此时需要根据返回值和errno来判断数据是否已经发送完毕。
网络信息API
可以用主机名来代表ip地址,也可以用服务名称来代表端口号。
gethostbyname和gethostbyaddr
可以通过名字和地址获取主机的完整信息。(先从/etc/hosts中找,再从DNS找)
getservbyname和getservbyport
可以通过名字和端口号获取服务的完整信息。(从/etc/services读取服务信息)
这四个函数都不可重入,不是线程安全的。函数名后加_r
代表可重入的。
getaddrinfo
可以根据主机名获取IP地址,也可以通过服务名获得端口号,是否可重入取决于其内部的gethostbyname和getservbyname是否是可重入的。
1 | int getaddrinfo(const char* hostname, const char* service, const struct addrinfo* hints, struct addrinfo** result); |
- hostname可以是主机名也可以是IP地址
- service可以是服务名也可以是端口号
- hints可以为NULL
- results指向一个addrinfo类型的链表
getnameinfo
获取主机名和服务名
高级IO函数
Linux提供了很多高级的IO函数,他们在特定的情况下性能很优异,这些函数分为三类:
- 创建文件描述符的函数,如pipe、dup/dup2函数
- 读写数据的函数,如readv/writev、sendfile、mmap/munmap、splice和tee函数
- 用于控制I/O行为和属性的函数,如fcntl函数
PIPE函数
pipe函数用于创建一个管道,以实现进程间通信。
1 | int pipe(int fd[2]); |
- 通过pipe函数创建的两个文件描述符fd[0]和fd[1]分别构成管道的两端,只能从fd[1]写,只能从fd[0]读,要实现双向传输需要两个管道。
- 默认情况下管道是阻塞的。
- 如果写端的引用计数为0,则读端的read操作返回0,代表读到了文件结束标记。如果读端的引用计数为0,写端的写操作会失败,出发SIGPIPE信号。
还有一个socketpair函数,它可以方面的创建双向管道。
dup函数和dup2函数
1 | #include <unistd.h> |
这两个函数可以复制一个文件描述符,两者的区别在于dup返回的文件描述符返回的是最小的未被使用的描述符,dup2返回的是指定的描述符newfd。
通过dup和dup2创建的文件描述符并不继承原文件描述符的属性,比如close-on-exec和non-blocking等。
readv函数和writev函数
readv将数据从文件描述符读到分散的内存块中,writev将多块分散的内存数据写入到文件描述符中。这两者相当于简化版的sendmsg和recvmsg函数。

如果想要向对方发送多个缓冲区上的数据,则没有必要将他们拼接到一个缓冲区上在发送,而是可以直接通过writev来发送。

sendfile函数
sendfile可以直接在两个文件描述符之间传递数据(完全在内核空间操作),从而避免了在内核缓冲区和用户缓冲区之间的数据拷贝,效率很高,称为零拷贝。in_fd必须是一个文件描述符(因为sendfile底层需要使用mmap将in_fd进行映射),out_fd在Linux2.6.33后可以是任何文件。
ssize_t sendfile(int out_fd, int in_fd, off_t* offset, ssize_t count)
,
mmap和munmap函数
mmap可以申请一段内存,用于进程间通信的,也可以将文件映射到内存中。

- start指定了映射到虚拟内存中的地址,可以为空
- length指定了内存段的长度
- prot参数用来设置内存段的访问权限,可读可写可执行
- flags指定一些标志,比如是否在进程间共享,是否是从文件映射来的。
成功返回指向内存区域的指针,失败返回MAP_FAILED((void*)-1)
splice函数
用于在两个文件描述符之间移动数据,也是零拷贝数据。(两个文件描述符中,有一个必须是管道)
1 | #include <fcntl.h> |
tee函数
在两个管道文件描述符之间复制数据,他不消耗数据。
1 | ssize_t tee(int fd_in, int fd_out, size_t len, unsigned int flags); |
fcntl函数
fcntl和ioctl都可以对文件描述符进行各种操作,ioctl比fcntl能执行更多的控制,对于控制文件描述符常用的属性,fcntl函数是POSIX规定的首选方法。
1 | int fcntl(int fd, int cmd, ... /* arg */ ); |

fcntl常用来将文件描述符设置为非阻塞的。

Linux服务器程序规范
- 服务器程序以守护进程形式存在,父进程一般是init进程
- 服务器程序一般有日志系统,可以输出到文件,一般在
/var/log
下,有的服务器还能输出日志到专门的UDP服务器。 - Linux服务器程序一般是以非root身份运行的,如mmysqld、httpd、syslogd,分别有自己的运行账户mysql、apache、syslog。
- 可以通过命令行选项和配置文件对服务器程序进行配置,大部分配置文件存放在
/etc
目录下 - 服务器启动时会生成一个PID文件存入
/var/run
目录下,以记录该程序的PID,以便于其他程序查询。
日志
Linux系统日志
Linux一般使用rsyslogd(syslogd)这个守护进程来处理日志。该进程可以同时接收来自用户进程和内核的日志。
- 用户进程是通过调用syslog函数来生成系统日志,该函数将日志输出到UNIX本地域socket类型的
/dev/log
上。rsyslogd则监听该文件以获取用户进程的输出。 - 内核日志是通过printk等函数打印到内核的环形缓存中,环形缓存的内容直接映射到
/proc/kmsg
文件中,rsyslogd通过读取该文件获取内核日志。
rsyslogd收到用户或内核的日志后,会根据配置文件将日志输出到日志文件。

syslog函数
1 | #include <syslog.h> |
该函数使用可变参数来结构化输出。
priority,是设施值和日志级别的按位与,设施值的默认值是LOG_USER, 日志级别有如下几类:
可以使用下面的函数开启logger并设置syslog的默认输出方式:
1 | void openlog(const char *ident, int option, int facility); |
ident指定的字符串被存放到日志消息的时间之后,它通常被设置为程序的名字
option参数对后序syslog调用的行为进行配置,它可以如下值的组合
facility可以修改syslog函数中的默认设施值,
通过如下函数设置日志掩码:
1 | int setlogmask(int maskpri); |
用户信息
很多服务器进程需要以root身份启动,但实际运行时,需要切换为普通用户身份运行,以保证安全性。
UID、EUID、GID和EGID

uid表示的是进程的创建者,euid表示的是进程对资源的实际访问权限。
- 通常一个进程能访问什么资源,取决于创建它的进程(uid,从用户的角度上看的)的权限。
- 使用euid后,一个进程能访问什么资源,取决于该程序的所有者(从文件系统的层面上看的)的权限
比如su程序的所有者是root,并且他设置了set-user-id标志,那么通过非root用户执行该程序时,有效用户是root。有效用户为root的进程称为特权进程。
1 | #include<unistd.h> |
切换用户
通过setgid和setuid实现。
进程间关系
进程组
Linux下用户有用户id和用户组id
Linux下的文件对应一个所有者用户、同组用户和其他用户
Linux下的每个进程都隶属一个进程组,都有一个PID和PGID(进程组ID)。每个进程组都有一个首领进程,他的PGID和PID是相同的,进程组会一直存在直到所有进程都退出。
1 | int setpgid(pid_t pid, pid_t pgid) |
该函数用于将PID为pid的进程的PGID设置为pgid。
- 如果pid和pgid相同,则将该进程从原进程组分离出来,并设置为进程组首领。
- pid为0,则默认将当前进程的PGID设置为pgid。
- pgid为0,则使用pid作为目标PGID。
一个进程只能设置自己或者其子进程PGID,
会话
一些有关联的进程将形成一个会话(session),下面的函数用于创建一个会话。
1 | pid_t setsid(void); |
该函数不能有进程组的首领进程调用,当非首领进程调用该函数时,会产生创建一个新会话,并且:
- 调用进程成为会话的首领,此时该进程是新会话的唯一成员
- 新建一个进程组,调用进程成为该组的首领
Linux进程没有提供所谓会话ID的概念,Linux认为它等于会话首领所在的进程组的PGID。提供了如下的方式读取:
1 | pid_t getsid(pid_t pid); |
用ps命令查看进程关系
1 | $ ps -o pid,ppid,pgid,sid,comm | less |
这三个命令创建了一个会话和两个进程组,bash是会话的首领也是进程组3535938的首领,ps命令是进程组3537268的首领。

系统资源限制
Linux上运行的程序会受到系统资源限制的影响,比如物理设备限制(CPU、内存),Linux系统资源限制可以通过如下的函数读取和设置:
1 | #include<sys/resource.h> |
rlim_cur是指定资源的软限制,而rlim_max是硬限制,(CPU时间超过软限制时,会由系统向进程发送SIGXCPU, 文件尺寸大于软限制时,会由系统向进程发送SIGXFSZ信号)

改变工作目录和根目录
当我们使用相对路径时,系统会以工作目录为起点来查找文件。
使用绝对路径时,使用根目录为起点来查找文件。如果使用
chroot /usr/local
,那么这个进程的根目录就会变为/usr/local
,此时,即使执行ls /
,看到的也是/usr/local
目录下的内容。
获取进程当前工作目录和改变进程工作目录的函数分别是:
1 |
|
改变进程根目录的函数是chroot(只有特权进程才能改变根目录):
1 | int chroot(const char* path); |
服务器程序后台化
可以使用daemon库函数来实现,也可以亲手实现(创建子进程,关闭父进程,使其父进程变为init,更改文件权限掩码为0,设定本进程为进程组的首领,切换工作目录,关闭输入输出,把输入输出重定向到dev/null
)
关闭标准输出后,如果程序还在使用printf打印信息,这些信息会被放到缓冲区,所以建议使用
/dev/null
来让屏蔽输出。
高性能服务器程序框架
三个主要模块:
- I/O处理单元:四种IO模型和两种事件处理模式
- 逻辑单元:两种高效并发方式,以及高效的逻辑处理方式——有限状态机
- 存储单元
服务器模型
C/S模型
如下,使用的是IO复用技术之一的select系统调用。

P2P
服务器编程框架
服务器的基本框架都是一致的,如下:

该图各个部分的功能如下:

- IO处理单元负责管理客户连接,(数据收发,optional,取决于事件处理模式)
- 逻辑单元通常是一个进程、线程、逻辑服务器
- 网络存储单元
IO模型
socket默认是阻塞的,可以通过socket的第二个参数或fcntl系统调用来将其设置为非阻塞的。阻塞的概念适用于所有文件描述符,阻塞的文件描述符称为阻塞IO,否则称为非阻塞IO。
- 如果是阻塞IO,当没有完成操作时,该进程会被阻塞,直到完成操作。可能被阻塞的系统调用包括accept、send、recv、connect。
- 如果是非阻塞IO,不管事件有没有发生,系统调用都会立刻返回。如果时间没有立刻发生,系统调用会返回-1,这和出错的情况一样,所以还必须根据errno来区分这两者情况。
- 对accept、send、recv而言,事件未发生时,errno被设置为EAGAIN或EWOULDBLOCK(期望阻塞)。
- 对connect而言,事件未发生时,errno被设置为EINPROGRESS(正在处理)。
非阻塞IO必须和其他IO通知机制一起使用,比如IO复用和SIGIO信号。
- IO复用,应用程序通过IO复用函数向内核注册一组事件,内核通过IO复用函数将其中就绪的事件通知应用程序。常用的IO复用函数包括
select, poll, epoll_wait
。 - SIGIO信号也可以用来报告IO事件,可以为一个文件指定一个进程。当文件内容改变时,会给对应进程发SIGIO、SIGURG信号。见6.8,10。
阻塞IO、IO复用、信号驱动IO都是同步IO模型。那什么是异步IO模型呢?
对于同步IO而言,需要等待操作完成(通过阻塞或轮询,epoll_wati也会阻塞),才会执行其他任务,整个IO过程由调用方管理。(比如recv,需要等待接收缓冲区非空,才会返回,并接收数据)
对于异步IO而言,不需要等待任何事件,立刻返回,具体操作由操作系统完成。
对异步IO而言,用户可以直接对IO执行读写操作,这些操作告诉内核用户读写缓冲区的位置,以及内核通知应用程序的方式。同步IO需要用户自行执行IO操作,而异步IO由内核来执行IO操作(数据在用户缓冲区与内核缓冲区的移动是靠内核完成的),并在完成时通知程序。
两种高效的事件处理模式
服务器程序需要处理三类事件:IO事件、信号和定时事件。
两种事件处理模型:Reactor和Proactor。通常用同步IO实现Reactor模式,而使用异步IO实现Proactor模式。(也可以使用同步IO实现Proactor模式)
Reactor模式
Reactor模式:主线程(IO处理单元)只负责监听文件描述符,有的话就通知工作线程(逻辑单元)。除此之外,主线程不做其他实质性的工作。接受连接、读写数据、处理客户请求均在工作线程完成。
使用同步IO模型(比如epoll_wait)实现的Reactor模式的工作流程如下:(可能阻塞的操作需要通过epoll_wait来统一监测,比如从socket读、写)
- 主线程往epoll内核事件表注册socket上的读就绪事件
- 主线程调用epoll_wait阻塞,等待socket上有数据可读。
- socket上有数据可读时,epoll_wait会通知主线程,主线程将socket可读事件放入请求队列。
- 睡眠在请求队列上的某个工作线程被唤醒,他从socket读取数据,并处理客户请求,生成结果。然后往epoll内核事件表注册该socket上的写就绪事件。
- 主线程调用epoll_wait阻塞,等待对应socket上可以写数据。(有足够的缓冲区)
- 当socket可写时,epoll_wait通知主线程,主线程将socket可写事件放入请求队列。
- 睡眠的某个工作线程被唤醒,往socket上写入结果。

所有工作线程都会根据事件的类型来决定处理方式,是读、操作还是写。所以没必要区分读工作线程和写工作线程。
Proactor模式
Proactor模式:Proactor模式将所有IO操作交给主线程和内核来处理,工作线程仅仅负责业务逻辑。
使用异步IO模型(aio_read和aio_write)实现的Proactor模式的工作流程是:
- 主线程调用aio_read函数向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读完成后通知主线程的方式(比如信号)。
- 主线程继续处理其他逻辑
- 当socket上的数据被读到用户缓冲区时,内核向主线程发一个信号,通知数据可用。
- 主线程使用预定义的信号处理函数(sighandler)选择一个工作线程来处理客户请求。工作线程执行完后,调用aio_write向内核注册socket上的写完成事件,并告诉内核用户缓冲区的位置,以及操作完成后如何通知主线程。
- 主程序继续处理其他逻辑
- 当数据被写到socket后,内核向应用程序发一个信号,以通知应用程序是否已经发送完毕。
- 应用程序使用预定义好的信号处理函数来完成善后,

这里的epoll_wait只需要监听连接事件,而不用监听读写事件。
模拟Proactor模式
由主线程(代替内核)执行数据读写操作,然后向工作线程通知这一事件,这样工作线程也只需要对数据进行处理。

两种高效的并发模式
并发编程指IO处理单元和多个逻辑单元之间协调完成任务的方法。分为半同步/半异步,领导者/追随者模式。
半同步/半异步方式
这里的同步和IO模型中的同步和异步是不同的概念。
- 在IO模型中,同步和异步区分的是内核向应用程序通知的是那种IO事件(是就绪事件还是完成事件),以及由谁来完成IO读写。
- 在并发模式中,同步指的是完全按照代码序列的顺序运行,异步指的是程序的执行需要由系统事件来驱动。常见的系统事件包括中断、信号。

按照同步方式运行的线程称为同步线程,按照异步方式运行的线程称为异步线程。异步线程的执行效率高,但复杂。同步线程效率低,但简单。对应服务器,使用半同步/半异步模式实现。
逻辑单元部分是同步的,IO处理单元部分是异步的。(逻辑单元部分是按照代码顺序执行的,IO处理单元部分是根据各种事件运行的)
事件处理模式可以和IO模型接合起来:
半同步半反应堆模式(并发模式是半同步半异步,事件处理模式是reactor模式)

一种高效的半同步半异步模式:主线程只管理监听socket,工作线程管理连接socket。(这不就异步吗,为什么还要叫半同步,/晕晕)

领导者/追随者模式
多个工作线程轮流获取事件源集合,轮流监听。如果当前领导者进程检测到IO事件,首先要从线程池选择新的领导者线程,这个新的领导者线程等待IO事件,原来的领导者线程开始处理IO事件。

句柄集,管理若干句柄(IO资源,即若干socket),领导者调用绑定到句柄上的事件处理器来处理事件
线程集,所有工作线程(领导者+追随者线程)的管理者,负责线程同步以及新领导的推选。线程集中的线程必然处于如下状态之一:
- Leader:线程处于领导者状态,在等待句柄集上的IO事件。
- Processing:线程正在处理事件。领导者检测到IO事件后,可以转换到Processing态,并选新领导者;也可以指定其他Follower来处理事件。Processing态的线程处理完后,如果当前没有Leader,则自己当Leader。
- Follower:可以调用线程集的join方法成为新Leader,也可以被Leader指定任务。
事件处理器和具体的事件处理器
有限状态机——逻辑单元内部的高效编程方法
应用层协议比如HTTP头部包含数据包类型字段,可以将类型映射为逻辑单元的一种执行状态,比如GET,HEAD,POST。
- 状态独立的FSM:不能在状态之间转换。
- 带状态转移的FSM:可以在内部驱动下进行状态转移。
例如HTTP头部解析,读到空行代表头部结束,需要使用一个主状态机来判断头部是否结束,还需要使用一个从状态机对每个请求行进行解析。
- 主状态机:包含两个状态,表示当前读取的是头部字段还是请求行。
- 从状态机:包含三个状态,表示当前读取的行正确、错误还是不完整。
- 服务器处理HTTP的结果包含如下几种:NO_REQUEST, GET_REQUEST, BAD_REQUEST, FORBIDDEN_REQUEST, INTER_REQUEST, CLOSE_CONNECTION。
提高服务器性能的建议
池
直接从池中获取资源,比动态分配资源的速度要快很多。
常见的比如:
- 内存池,用于socket的接收和发送缓存。
- 进程/线程池,直接从进程池取得一个执行实体,而无需动态的调用fork。
- 连接池,用于维护服务器和数据库程序的一组连接。
数据复制
gao高性能服务器应该避免不必要的数据复制,尤其是发生在内核和用户代码之间的复制。
如果没必要,则不需要将数据读到用户缓冲区。比如发送服务器上的文件时,可以直接通过sendfile发送,而不是先读到应用程序的缓存,再发送。
进程之间数据访问也应该被避免,当两个工作进程需要传递大量数据时,使用共享内存,而不是管道或消息队列。
上下文切换
不应使用过多的工作线程,避免太多的上下文切换。
锁
避免锁,或者使用细粒度的锁,使用读写锁。
IO复用
IO复用使监听多个文件描述符成为了可能。
IO复用是串行的(不使用多进程等手段时),每次只能选择一个文件描述符再执行。
常见的包括select、poll和epoll。
select系统调用
1 | #include <sys/select.h> |
nfds:代表被监听的文件描述符的总数,即为所有文件描述符的最大值加一,一定要注意,不然没法监听(0-maxnum总共有maxnum+1个数)。
readfds、writefds、exceptfds参数分别指向可读可写和异常事件对应的文件描述符集合。fd_set是一个结构体,其中包含了一个整型数组,数组的每一位代表一个fd。
timeout用来设置select函数的超时时间,timeval结构体有两个值代表秒和微秒
- 如果设为0,则立刻返回
- 设为NULL,则一直阻塞,直到某个文件描述符就绪
- 成功返回就绪文件描述符的总数
- 给定时间内,没有文件描述符就绪,返回0
- 失败时,返回-1并设置errno
- 如果在等待期间,程序收到信号,select返回-1并设置errno为EINTR。
文件描述符就绪条件
在如下情况下,socket可读:(或者说,在如下情况下,直接调用的recv会返回)
- socket内核缓冲区的字节数大于等于低水位标志SO_RECVLOWAT(即1),调用read后返回大于0的数。
- socket的对方关闭连接,调用read后返回0。
- 监听socket上有新的连接请求。(通知后,应用程序可以根据文件描述符判断是listenfd,然后用accept来处理新的连接请求,如果不是listenfd,则进行其他处理)
- socket上有未处理的错误,使用getsocopt来读取和清除该错误。
在如下情况下,socket可写:(或者说,在如下情况下,直接调用的send会返回)
- socket内核发送缓冲区可以字节数大于等于低水位标志SO_SNDLOWAT(即1)
- socket的写操作被关闭,这是继续往socket写数据时会触发SIGPIPE信号。
- socket使用非阻塞connect连接成功或者失败之后。
- socket上有未处理的错误,使用getsocopt来读取和清除该错误。
服务端网线被拔了,重新接上后。只有客户端维护着连接,当他往管道写入数据时,会受到一个复位报文段。
select能处理的异常情况只有一种:socket上接收到带外数据。
处理带外数据
SELECT返回后,判断sockfd的读、写、异常位是否被置位,然后执行针对性的操作。
poll系统调用
poll系统调用和select类似,也是在指定时间内轮询一定数量的文件描述符,已测试是否有就绪者。
1 | #include <poll.h> |
fds是一个pollfd结构类型的数组,它指定我们所有感兴趣的文件描述符上发生的可读可写异常事件
1
2
3
4
5struct pollfd{
int fd;
short events;//注册的事件
short revents;//实际发生的事件,由内核来写
}nfds指定第一个参数,数组的大小
timeout指定超时值。
epoll系列系统调用
内核事件表
epoll与select和poll不同的是,将事件表放到了内核中,而不是通过函数参数传入传出。所有epoll需要一个额外的文件描述符标识放到内核中的事件表。
创建内核事件表,获取文件描述符:
1 | #include<sys/epoll.h> |
操作内核事件表:
1 | int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event) |
fd指的是要操控的文件描述符
op有三种
- EPOLL_CTL_ADD
- EPOLL_CTL_MOD
- EPOLL_CTL_DEL
event指定事件,他是epoll_event结构类型如下
1
2
3
4struct epoll_event{
__uint32_t events;
epoll_data_t data;
}其中events成员描述事件类型,事件类型的种类和poll很相似。但多了两个:EPOLLET、EPOLLONESHOT。
epoll_data_t是一个union,如下(这些数据是epoll_wait返回时用的,返回的是epoll_event类型,通过这个类型的data的fd需要能够获得文件描述符)
1
2
3
4
5
6typedef union epoll_data{
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
}epoll_data_t;
epoll_wati函数
他在一段超时时间内等待一组文件描述符上的事件,
1 | #include <sys/epoll.h> |
成功时返回文件描述符的个数,失败时返回-1并设置errno。
- timeout
- maxevents,必须大于0
- events,检测到事件,就将事件放到第二个参数里面,个数由返回值指出。
LT和ET模式
epoll对文件描述符的操作有两种模式:LT和ET(LT是默认模式,ET更高效)。
具体代码可见数据以及3090上。
ET模式不能搭配阻塞IO使用。
因为当第一次收到数据时,ET模式的会循环使用recv读取数据,当读完数据时,也会使用recv继续读取,这时因为IO是阻塞的,那么程序就会阻塞在IO操作上。
相对的,使用非阻塞IO时,recv返回后,会根据errno来判断是继续读,还是暂停读。
EPOLLONESHOT事件
为了防止工作进程在处理数据时,又收到了新的数据,这会唤醒另一个工作线程来处理同一个socket上的数据,导致冲突。我们希望任何时刻,同一个socket只能被一个工作进程处理。
对于注册了EPOLLONESHOT事件的文件描述符来说,操作系统只能触发一次该文件描述符的的事件。只有通过epoll_ctl重置了该文件描述符上的EPOLLONESHOT后,该文件描述符才会触发新事件。(工作进程一旦处理完该socket,就应该立刻重置EPOLLONESHOT)
如果一个工作线程在处理某个socket,这时有新的数据来了,则新的数据也会被该工作线程处理。
三组IO复用函数的比较
相同点:可以监听多个文件描述符,可以等待timeout指定的时间,返回值代表事件的数量。
不同点:
- select和poll每次返回整个用户注册的事件集合(包括注册的和没注册的),epoll则只返回就绪事件
- poll和epoll用参数指定最大文件描述符数量,他们都能达到系统允许打开的最大文件描述符数据,(65535或更大,
cat /proc/sys/fs/file-max
),select则有限制。 - select和poll只能工作在低效的LT模式,而epoll可以工作在高效的ET模式,并支持EPOLLONESHOT事件。
- 从实现原理上说,select和poll使用轮询的方式,epoll使用回调的方式。epoll适用于连接数量多但活动连接少的情况下。

IO复用的高级应用一:非阻塞connect
EINPROGRESS(代表当前socket是非阻塞的,连接没法立刻建立,可以通过io复用通知,时间合适时再建立)
The socket is nonblocking and the connection cannot be completed immediately. (UNIX
domain sockets failed with EAGAIN instead.) It is possible to select(2) or poll(2)
for completion by selecting the socket for writing. After select(2) indicates
writability, use getsockopt(2) to read the SO_ERROR option at level SOL_SOCKET to de‐
termine whether connect() completed successfully (SO_ERROR is zero) or unsuccessfully
(SO_ERROR is one of the usual error codes listed here, explaining the reason for the
failure).
errno 是一个全局变量,用于指示最近一次系统调用失败的原因.
so_error 是一个套接字选项,用于获取与套接字相关的异步错误。(前文说过哟,在4个情况下,包括出错,send、recv调用都会返回,然后通过getsockopt来获取信息)
这避免了客户端调用connect时的阻塞等待,当需要连接多个服务端时效果好。
IO复用的高级应用二:聊天室程序
客户端需要读取用户输入,将数据发给服务器;从服务器接收数据,将数据显示出来。
服务器需要监听端口,并转发客户端的数据。
客户端
使用poll同时监听用户输入和网络连接,使用splice将输入定向到网络连接并发送。零拷贝。
IO复用的高级应用三:同时处理TCP和UDP服务
服务器也需要监听多个socket,比如TCP socket或者 UDP socket,为此,需要使用IO复用。
nc命令,(非书籍内容)
在某个端口监听:
nc -l -p 8888
连接某个端口:
nc -v 127.0.0.1 8888
(TCP) 或者nc -vu 127.0.0.1 8888
(UDP),连接后可以正常进行通信,比如发送HTTP报文等。传输文件:在一端使用比如
nc -l -p 8888 > test.txt
在另一端使用nc -nv 127.0.0.1 8888 < example.txt
,也可以反过来用。传输目录:和ssh类型,发送端先将文件夹通过tar压缩为一个压缩包,然后重定向为nc的输入,将数据发给对方:
tar -cvf - tools | nc -nv 127.0.0.1 888
(其中-
表示将压缩的数据输出到标准输出1,这又被通过管道传给了第二个命令的输入0,前面的重定向也是类似的>
)。发送端也是类似的:nc -l -p 10089 | tar -xvf -
。通过ssh传送:
tar -cvf - tools |ssh -p 3090 xxx@218.194.61.218 'pwd;tar -xvf -'
远程控制(需要支持
-c, -e
命令,可能需要先卸载netcat然后安装增强版ncat):指定-c, -e
命令后,会再连接后开启一个新的bash来执行命令。nc -lp 8888 -c bash
指定ps显示方式:
ps -eo pid,ppid,pgid,sid,comm,user
超级服务xinetd
Linux因特网服务inetd
是超级服务,管理多个子服务,即监听多个端口。
随着systemd等更现代的系统和服务管理工具的出现,xinetd和inetd的使用逐渐减少。
xinetd配置文件
信号
服务器比如处理(或者忽略一些常见的信号,以免终止)。
Linux信号概述
发送信号
发送信号的函数是kill函数,
1 | #include <sys/types.h> |
pid
- pid > 0, 代表把信号发给PID为pid的进程
- pid = 0,代表把信号发给本进程组内的其他进程
- pid = -1,代表把信号发给除init进程外的所有进程(前提是有权限)
- pid < -1, 代表发给组ID为-pid的进程组的所有成员。
sig,信号值都大于0。取0时代表不发送任何信号。
失败时返回-1,并设置errno,
- EINVAL, 代表无效的信号
- EPERM, 代表该进程没有权限发送给任何一个目标进程
- ESRCH, 代表目标进程不存在
信号处理方式
目标进程在收到信号后,需要定义一个接收函数来处理。
1 | #include<signal.h> |
信号处理函数只带有一个整形参数,指示信号类型,此函数应该是可重入的。
除了用户自定义信号函数外,还有其他处理方式:
1 | #define SIG_DFL ((__sighandler_t) 0) |
- SIG_IGN, 忽略目标信号函数
- SIG_DFL, 表示使用信号的默认处理方式。默认的处理方式有如下几种:结束进程、忽略信号、暂停进程、继续进程、结束进程并生成核心转储文件等。
Linux信号
Linux的可用信号都定义在bits/signum.h
中,包括标准信号和POSIX实时信号。几个比较常用的:
- SIGHUP: 检测到控制终端挂起或者控制进程死亡时,进程会收到 SIGHUP。现在操作系统,该信号通常意味着使用的 虚拟终端 已经被关闭。许多 守护进程 在接收到该信号时,会重载他们的设置和重新打开日志文件(logfiles),而不是去退出程序。nohup 命令用于无视该信号。
- SIGPIPE:往读端被关闭的管道或者socket连接中写数据时会收到这个信号。
- SIGURG
- SIGALRM
- SIGCHLD
中断系统调用
处于可中断阻塞态的进程可能被信号中断,并将errno设置为EINTR。被中断后,进程被唤醒并处理信号。可以为信号设置SA_RESTART来自动重启被信号中断的系统调用。
信号函数
signal系统调用——为信号设置处理函数的一种方式
1 | #include<signal.h> |
- sig,指明信号类型
- _handler,指定信号sig的处理函数,前面定义的SIG_IGN和SIG_DFL也属于这个类型。
返回值是返回上一次调用signal函数时传入的函数指针。
sigaction系统调用——为信号设置处理函数的一种更健壮的方式
1 | #include<signal.h> |
- sig参数指出要捕获的信号类型
- act指定信号对应的新的处理方式
- oact参数则返回信号对应的旧的处理方式
act和oact参数都是sigaction结构体类型的指针。
1 | struct sigaction { |
- sa_hander成员用来指定信号处理函数。
- sa_mask,在信号处理函数执行期间用来屏蔽一组信号。
- sa_flags用来指定程序收到信号时的行为。
信号集
信号集函数
Linux使用数据结构sigset_t来表示一组信号。
1 | typedef struct{ |
set代表他是一个集合,_t代表它是一种数据类型
进程信号掩码
前面提到过,可以通过sa_mask成员来设置进程的信号掩码,也可以通过如下的方式设置。(区别在于sigaction 中的 sa_mask生效范围是信息处理函数,sigprocmask的作用范围是整个进程)
1 | #include<signal.h> |
- set指定新的信号掩码,
- oset输出原来的信号掩码,
- how指定了设置进程信号掩码的方式。比如SIG_BLOCK代表将新的进程信号掩码设定为当前值以及_set指定值的并集。
可以将set设为NULL, 然后仍然可以利用oset参数来获取进程当前的信号掩码。
被挂起的信号
当给一个进程发送一个被屏蔽的信号时,操作系统将该信号设置为进程的一个被挂起的信号,如果取消对被挂起信号的屏蔽,则它能立刻被进程接收到。通过如下函数可以获得进程当前被挂起的信号集。set保存的是被挂起的信号集。
1 | int sigpending(sigset_t* set); |
即使某个信号被发送多次,sigpending函数也只反映一次。
统一事件源
信号是一种异步事件,为了让信号处理函数快速执行,避免屏蔽掉信号。使用一种解决方法:把信号的主要处理逻辑放到程序的主循环中,而当信号处理函数被触发时,他只是通知主循环程序接收信号,并把信号值传给主循环。
通常使用管道来传递,主程序使用IO复用系统调用来监听管道上的可读文件。
网络编程相关信号
SIGHUP
- 对于在控制终端中执行的进程,当进程与控制制失去连接时,就会收到这个信号。当终端被关闭时,就会触发SIGHUP信号。
- 对于守护进程,他们并没有和终端相关联,他们收到SIGHUP信号时,可以重新加载配置文件或打开日志文件,以适应新的配置。
strace -p 7438 &> a.txt
strace可以跟踪该进程的系统调用和信号;&>代表将标准输出和标准错误输出都输出到后面的文件中。(只用>时代表只处理标准输出)
SIGPIPE
往读端关闭的管道或者socket连接中写数据会引发SIGPIPE信号并将errno设置为EPIPE。收到SIGPIPE后,进程的默认行为是终止该进程,可以配置信号处理函数来解决。
可以通过设置send函数的MSG_NOSIGNAL标志来禁止写操作触发SIGPIPE信号,在这种情况下,就可以通过errno值来判断socket的读端是否已经关闭。
也可以通过IO复用系统调用来判断,以POLL为例:pollhup代表连接被双方关闭,pollrdhup代表本端没法继续读数据了(要么是连接关闭,要么是对方关闭了写端,或者本端口关闭了读端,参考:linux - POLLHUP vs. POLLRDHUP? - Stack Overflow)
上面主要介绍了几种判断连接是否存在的方式:
- 使用send,看会不会触发sigpipe异常
- 使用errno判断
- 使用IO复用系统调用判断
SIGURG
如何判断是否有带外数据到达呢:
- 使用IO复用技术,比如监听的事件中加上一个EPOLLPRI
- 使用SIGURG信号
定时器
Linux提供了三种定时方法:
- socket选项 SO_RCVTIMEO 和 SO_SNDTIMEO
- SIGALRM信号
- IO复用系统调用的超时参数
SO_RCVTIMEO和SO_SNDTIMEO
这两个可以设置socket接收数据和发送数据的超时时间。这两个选项只对如下几种标准的系统调用有用。

1 | ret = setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, &timeout, len); |
SIGALRM信号
基于升序链表的简单定时器
通过升序链表构建一个简单的定时器,只需要定期的触发定时器类的tick函数即可。
处理非活动连接
虽然可以在socket选项中KEEPALIVE来激活定期检查机制,但也可以考虑在应用层实现类似于KEEPALIVE的机制。
高性能定时器(容器)
时间轮
以固定的频率调用tick函数,并检测到期的定时器,执行定时器上的回调函数。
时间堆
将超时时间最小的定时器的超时值作为心跳间隔,到期后调用tick函数。执行回调函数后,再从剩下的定时器中找到超时时间最小的一个,将这段时间作为下一次心搏间隔。最小堆很适合这种定时方案。
高性能IO框架库Libevent
常见的库包括ACE、ASIO和Libenvet。
IO框架库
封装了较为底层的系统调用,给应用程序提供了一组更便于使用的接口。
各种IO框架库的实现原理类似,要么是Reactor,要么是Proactor。
基于Reactor模式的框架库包含如下几个组件:句柄、事件多路分发器、事件处理器、具体的事件处理器、Reactor。
句柄
句柄是一种抽象的标识符,用于表示对系统资源的引用。比如IO事件的句柄是文件描述符,信号事件的句柄是信号值。
事件多路分发器
在事件循环中,程序需要循环的等待并处理事件。等待事件一般使用IO复用技术实现。IO框架库一般会将系统支持的各种IO复用技术封装成统一的接口,成为事件多路分发器。其内部会调用select、poll、epoll_wait等函数。

事件处理器、具体的事件处理器
负责执行事件对应的业务逻辑,它通常包含回调函数,这些回调函数在事件循环中被执行。
Reactor
Reactor是IO框架库的核心,包含几种主要方法:
- handle_events,执行事件循环,等待事件,然后处理就绪事件对应的事件处理器。
- register_handler,
- remove_handler
多进程编程
fork系统调用
exec系列系统调用
僵尸进程
使用waitpid等待指定的子进程结束,设置options参数为WNOHANG可以让waitpid调用变为非阻塞的。他在任何情况下都会立刻返回,但需要根据返回值判断调用是否成功(和einprogress类似)
- 返回0, 子进程还没有结束
- 返回正数,子进程已经退出了
- 返回负数,调用失败
但是如果出现返回0的情况,此时必须等待子进程结束了之后再次调用waitpid,如果使用一个while循环不断判断,那这就与阻塞没有任何区别了,所以可以利用SIGCHLD信号。进程结束时,给他的父进程发一个SIGCHLD信号,这样父进程收到信号进入信号处理函数,就肯定说明子进程结束了,此时再使用waitpid。

管道
管道可以实现进程内部与外部之间的通信。
一对管道只能实现一个方向的数据传输。要实现父子进程的双向数据传输,就必须使用两个管道。socket编程接口提供了一个创建全双工socket的系统调用:socketpair。
管道只能用于有关联的进程之间通信(因为管道的文件描述符不是整个系统唯一的,而是进程唯一的),比如父子进程。而下面的方式可以用于无关联的多个进程之间的通信,因为他们使用一个全局唯一的键值来标识一条信道。
信号量
信号量原语
要编写有通用目的的代码,确保关键代码的独占式访问是非常困难的,Dekker和Perterson算法尝试从语言本身,而不是依赖内核来解决并发问题,但这会导致CPU利用率低下。
Linux信号量的API定义在sys/sem.h头文件中,主要包含三个系统调用:semget、semop和semctl。他们被设计为操作一组信号量,而不是单个信号量。(上面的这个信号量是System V信号量机制的一部分,而sem_open是POSIX标准的一部分,两种方式都可以获取信号量)
semget系统调用
1 | int semget(key_t key, int num_sems, int sem_flags); |
创建一个新的信号量集,或者获取一个已经存在的信号量集。key参数标识一个全局唯一的信号量集。
semop系统调用
semctl系统调用
特殊键值IPC_PRIVATE
向semget的参数key传递IPC_PRIVATE时,不管信号量存不存在,都会创建一个新的信号量,这个信号量只可以供该进程及其子进程(对子进程而言他能获得semid这个变量,这是能在子进程中使用的原因)使用,其他进程无法访问。
共享内存
最高效的IPC机制,不涉及任何数据传输。但必须使用其他手段来同步进程对共享内存的访问,以免出现竞态条件。
shmget系统调用(System V)
创建一段新的共享内存标识符,或者获取一段已经存在的共享内存标识符。
1 | int shmget(key_t key, size_t size, int shmflg); |
和semget系统调用一致,key是一个键值,表示一段全局唯一的共享内存。size指定共享内存的大小,如果只是获取已经存在的共享内存,则可以为0.
shmat和shmdt
shmat将共享内存标识符关联到进程的地址空间中。shmdt取消关联。
shmctl系统调用
shm_open(POSIX)
使用shm_open打开后再使用mmap进行关联,再使用ummap,shm_unlink。
消息队列
msgget
创建或者获取一个消息队列。
msgsnd
msgrcv
msgctl
在进程间传递文件描述符
传递一个文件描述符给另一个进程,并不是单纯的复制该文件描述符的值。而是要创建一个文件描述符,并且该文件描述符指向内核中相同的文件表项。可以通过代码实现。
多线程编程
Linux线程库
最常见的线程库是LinuxThreads和NPTL。(Native POSIX Thread Library)
旧版本Linux不支持内核线程时,可以使用clone系统调用+不复制地址空间来获得线程(但这个线程的pid与父进程不一致)。Linux2.6之后,提供了真正的内核线程,他
- 让内核线程不再是一个进程,避免了很多用进程模拟线程产生的语义问题。
创建线程和结束线程
- pthread_create
- pthread_exit,线程结束时最好调用如下函数,以确保安全,该函数不会返回。
- pthread_join,一个进程中的所有线程都可以调用pthread_join来回收其他线程。没有回收则会一直阻塞。
- pthread_cancel,希望终止或者取消一个线程。目标线程也可也配置能否被取消以及取消类型。
线程属性
- detachstate,包括JOINABLE和DETACH这两个状态,位于DETACH态时,结束时会自行释放占用的系统资源。
POSIX信号量
线程之间也需要控制对共享资源的访问。主要有三种用于线程同步的机制:POSIX信号量、互斥量和条件变量。
常见的POSIX信号量函数如下:(之前的semget都是System V标准下的信号量)
1 | int sem_init(sem_t* sem, int pshared, unsigned int value); |
互斥锁

属性
- pshared,互斥锁能否被不同的进程共享。
- type
- 普通锁(默认):够用但是有缺陷。比如对上锁的普通锁再次加锁会出现死锁。线程A对线程B加的锁进行解锁,以及解一个已经解开的锁会出现未定义行为。
- 检错锁,当遇到上面的三种错误行为,会报错。
- 嵌套锁,具备检错锁的特点,同时可以加锁多次,解锁多次。解锁次数与加锁次数相同后,其他线程才能获得这个锁。
条件变量
互斥锁用于同步对共享数据的访问,条件变量用于在线程之间同步共享数据的值。

使用wait等待条件达成,使用signal唤醒被阻塞的线程。条件变量可能被多线程所访问,所以需要使用互斥量来控制对条件变量。
为什么pthread_cond_wait里面会先释放锁,在申请锁,因为刚进函数时,需要等待条件达成,这里必然会阻塞,为了防止其他线程无法访问条件变量,需要先进行解锁,当再次被唤醒时,就意味着条件已经达成(或者是被信号中断),需要再次加锁,返回。这里进行一次加锁和解锁可能是为了保持一致性,毕竟进函数前已经加锁,出函数也应该加锁。
条件变量比信号量灵活一点,毕竟条件变量可以进行条件判断。而信号量只会判断是否为0.
线程同步机制包装类
为了复用代码,将前面的三种线程同步机制封装成3个类。
多线程环境
线程安全(可重入函数):一个函数能被多个线程同时调用并不发生竞态条件。
Linux库函数只有一小部分不可重入的,比如inet_ntoa、getservbyname和getservbyport函数。之所以不可重入,是因为其中使用了静态变量。Linux对很多不可重入的库函数都提供了可重入版本,这些可重入版本的函数名是在原函数名尾部加上_r。在多线程程序中调用库函数,一定要使用其可重入版本。
可重入函数使用互斥锁,或者避免静态变量来保证可重入。
线程与进程
如果在进程A中创建一个线程B,在现场将一个互斥锁加锁,再fork一个进程C,这时进程C中没有线程,但是互斥锁处于加锁状态。
解决方式:fork前确认所有锁都是解锁状态,比如使用pthread_atfork(),如果锁是解开的,那么prepare会正常调用,否则会报错(如果不是嵌套锁的话)。
1 | void prepare_fork() { |
线程与信号
sigprocmask:设置的信号屏蔽字(signal mask)会影响进程中的所有线程。
pthread_sigmask:设置的信号屏蔽字仅影响调用它的线程,而不会影响其他线程。
进程级信号(比如kill)发给进程后,可以被其中任意线程处理,每个线程可以配置信号掩码来判断能否处理信号(比如可以用一个单独的线程处理信号,其他线程屏蔽信号)。
线程级信号(pthread_kill)可以直接发送给目标线程。
信号处理函数作用于进程,一种类型的信号处理函数同进程只有一个。
可以使用信号处理函数,也可以使用sigwait,这两者都可以判断是否接受到信号。为了只让一个线程出现信号,一般方式是让所有线程都屏蔽某些信号,然后在某一个线程中使用sigwait来处理信号。
进程池以及线程池
这两个池很类似,之后都介绍进程池。
进程池的子进程数量在3-10个,而线程池的子线程数量和CPU数量差不多。
进程池子中的子进程运行着相同的代码,并且比较干净,因为不是从父进程fork而来。
getpid 实际返回的是调用线程所属组的ID(TGID), 而不是线程的PID。
gettid可以获取线程真实的PID。详见
man getpid
。From a kernel perspective, the PID (which is shared by all of the threads in a multithreaded process) is sometimes also known as the thread group ID (TGID). This contrasts with the kernel thread ID (TID), which is unique for each thread. For further details, see gettid(2) and the discussion of the CLONE_THREAD flag in clone(2).
主进程选择哪个子进程来为新任务服务:
- 使用某种算法来选择,比如随机和轮流算法。
- 主进程和所有子进程通过一个共享的工作队列来同步,子进程睡眠在该工作队列上,有新任务来时,将任务添加到工作队列上,这样有一个子进程将获得接管权,其他子进程继续睡眠。
选择子进程后,
父进程还需要通知子进程需要处理的数据,最简单的方式是预先在父子进程建一条管道,使用该管道通信。
至于线程,则可以直接将数据定义为全局的,毕竟这本来就是被所有线程共享的。

处理多客户


服务器调制、调试和测试
Linux对应用程序能打开的最大文件描述符数量有两个限制:用户级限制、系统级限制。
调整内核参数
几乎所有内核模块,都在/proc/sys
下提供了配置文件,也可也通过sysctl -a
来查看这些内核参数。
/proc文件系统是一个虚拟文件系统,它反映了当前内核的状态,所以通过修改/proc下的文件来调整参数只是临时的修改,重启后就会失效。要永久生效需要配置/etc/sysctl.conf
,并执行sysctl -p
使它生效。
/proc/sys/fs/file-max
, 系统级(不是用户)文件描述符限制/proc/sys/fs/epoll/max_user_watches
,单用户能往epoll事件表注册事件的总数。/proc/sys/net/core/somaxconn
, 指定listen监听队列中,能够建立完整连接并进入ESTABLISHED状态的socket的最大数目。/proc/sys/net/ipv4/tcp_max_syn_backlog
, 指定listen监听队列中,能够转移到ESTABLISHED或者SYS_RCVD状态的socket的最大数目。/proc/sys/net/ipv4/tcp_rmem
,指定一个socket的TCP写缓冲区的最小值、默认值、最大值。/proc/sys/net/ipv4/tcp_wmem
,指定一个socket的TCP读缓冲区的最小值、默认值、最大值。/proc/sys/net/ipv4/tcp_syncookies
,是否打开TCP同步标签,防止某个监听socket不停的收到同一个地址的连接请求而导致listen队列移除。(SYN泛红)
gdb调试
调试多进程时,可以用gdb attach到子进程进行调试。
调试多线程时:
- 可以使用info threads查看所有可以调试的线程。
- thread ID指定线程
- set scheduler-looking [off|on|step]来锁定当前线程,使当前线程可以运行,而其他线程停止运行。
压力测试
系统监测工具
常用的工具:tcpdump、nc、strace、lsof、netstat、vmstat、ifstat、mystat。
tcpdump
支持三种方式过滤包:
- 类型,支持的类型包括host、net、port、portrange。分别代表主机名(IP),用CIDR方法代表的网络地址,端口号,端口范围。比如:
tcpdump net 198.168.1.1/24
- 方向,用
src
指定源方向,而dst
指定目的方向。比如:tcpdump dst 10089
. - 协议,指定目标协议,比如:
tcpdump icmp
.
tcpdump甚至支持逻辑操作符:
tcpdump icmp host 192.168.1.140 and 192.168.1.117
,代表抓取主机140和主机117之间的icmp报文。
表达式复杂时,可以使用括号将其分组,不过需要使用反斜杠将其转义或者使用单引号将其括住。
lsof
列出当前系统打开的文件描述符的工具。
lsof -i [46][protocol][@hostname|hostaddr][:service|port]
,显示socket文件描述符。如:lsof -i 4TCP@127.0.0.1:8888
。lsof -u
,显示指定用户启动的所有进程的所有文件描述符。lsof -c
,显示指定的命令打开的所有文件描述符。lsof -p
, 显示指定进程打开的所有文件描述符lsof -t
,显示打开了文件描述符的进程的PID。
lsof显示的内容相当丰富:
- fd,文件描述符的描述,cwd代表进程的工作目录,rtd代表用户的根目录,txt代表进程运行的程序代码,mem表示映射到内存中的文件。有些FD是以数字+访问权限表示的,包括r,w,u(可读可写)。
- type,dir是目录,reg是普通文件,chr是字符设备,