学习Linux高性能服务器编程的一些收获

最近把 Linux高性能服务器编程 编程这本书给看了,之前在知乎上看好多人推荐这本书,于是乎在我看了这本书之后,我发现这本书真的很值得看,这本书适合有Linux基础的的人看,特别是看过一些成熟框架的人看,像redis,memcache,最好还有nginx,因为这本书介绍了服务器端开发的一些术语以及模式,如果没看过源码实现,这些理论我觉得会很空洞,即使书本提供了一些示例,看着也会比较吃力.

我这篇文章主要记录在这本书见到的新知识,虽然很多内容在之前有接触过,但是还是有一些内容让我眼前一亮.

Linux高性能服务器编程

这本书篇章布局也很合理,第一篇介绍了网络的一些基础知识,tcp,ip,dns,arp等等,这些本科的网络课都有学过,但是我强烈推荐好好掌握下tcpdump这个命令(第一次见到这个命令是在unp网络编程),对网络上的的数据包进行截获的包分析工具,例如可以查看网络建立的三次握手数据包收发过程以及四次挥手过程,还可以查看icmp等等,非常方便.当然像route,netstat,ifconfig,ping,traceroute也是需要掌握的,还有和域名解析相关的三个命令dig,host,nslookup.

这本书的第二篇前半部分介绍了网络编程的一些函数调用;中间部分介绍了与网络相关的三种事件,文件io读写事件,定时事件,信号事件以及如何统一这三种事件源;最后一部分介绍了服务器编程中常用的多进程和多线程实现.

这本书的第三篇介绍了高性能服务器优化和检测,主要就是服务器调试,包括资源的调整,gdb调试和压力测试;还有一些和服务器性能相关的命令,例如stat系列,netstat,vmstat,ifstat,mpstat等等.

接下来,我就介绍下这本书中,给我眼前一亮的东西.

sendfile,splice和tee函数


这三个函数在我没读这本书之前是没有遇到过的,unp网络编程我看了前半部分我也没见着,而且在redis和memcache我也没见着,关键是这三个函数效率很高,避免了用户态和内核态之间的数据拷贝.我经过一番思考之后,是因为服务器端程序都有业务逻辑,必须将数据拷贝至用户态,然后处理数据,最后再将数据拷贝进内核态发送出去,所以这三个函数在服务器上出场率不是很高;

sendfile函数

sendfile函数主要是用于在文件服务器上,在两个文件描述符之间直接传递数据(完全在内核中操作),从而避免了内核缓冲区和用户缓冲区之间的数据拷贝,效率很高,这种称为零拷贝.函数接口如下:

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

outf_fd为待写入的文件描述符,一般是一个socket,in_fd是一个写入文件描述符,一般是一个磁盘文件描述符,offset是一个输入流的偏移量,如果为空,则从输入流的起始位置开始;count是文件描述符in_fd和out_fd之间传递的字节数.函数成功调用返回传递的字节数,失败返回-1,并设置error值.

sendfile函数使用场景最常见就是发送文件:

  • 从磁盘文件读取数据至内核缓冲区,用sendfile函数直接在内核将数据拷贝至socket输出缓冲区,发送出去;

平时,我们如果要在网络上传输一个文件,要有以下步奏:

  1. 打开一个文件fd,将文件的数据首先从磁盘拷贝至内核缓冲区;
  2. 然后将内核缓存区数据拷贝至用户态缓冲区;
  3. 将用户态缓冲区的数据拷贝至socket输出缓冲区;
  4. 将socket缓冲区数据发送出去;

但是如果我们使用sendfile函数,则可以省去内核态拷贝至用户态这一过程:

  1. 打开一个文件fd,将文件的数据首先从磁盘拷贝至内核缓冲区;
  2. 将内核哈缓冲区的数据拷贝至socket输出缓冲区;
  3. 将socket缓冲区数据发送出去;

splice函数

这个函数主要是在两个文件描述符上移动数据,其中有一个必须是管道,主要是利用管道的在内核中缓冲区暂存数据,函数接口如下:

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

splice用于在两个文件描述符之间移动数据, 也是零拷贝。fd_in参数是待输入描述符。如果它是一个管道文件描述符,则off_in必须设置为NULL;否则off_in表示从输入数据流的何处开读取,此时若为NULL,则从输入数据流的当前偏移位置读入。fd_out/off_out与上述相同,不过是用于输出。len参数指定移动数据的长度。flags参数则控制数据如何移动:

  • SPLICE_F_NONBLOCK:splice 操作不会被阻塞。然而,如果文件描述符没有被设置为不可被阻塞方式的 I/O ,那么调用 splice 有可能仍然被阻塞。
  • SPLICE_F_MORE:告知操作系统内核下一个 splice 系统调用将会有更多的数据传来。
  • SPLICE_F_MOVE:如果输出是文件,这个值则会使得操作系统内核尝试从输入管道缓冲区直接将数据读入到输出地址空间,这个数据传输过程没有任何数据拷贝操作发生。

所以可以想象以下两个使用场景:

  1. 回显服务器 从socket读入数据,并用splice函数输出到管道的写端;然后从管道的读端用splice函数输出到socket,实现回显服务器.
  2. 文件拷贝 从一个磁盘文件读取数据,用splice函数输出到管道的写端;然后从管道的读端用splice函数输出到另外一个磁盘文件.

使用方法大概如下:

1
2
3
4
5
6
7
8
9
10
11
12
//这是测试示例主要部分
int pipefd[2];
ret = pipe(pipefd); //创建管道
assert(ret != -1);
//将connfd上的客户端数据定向到管道中
ret = splice(connfd, NULL, pipefd[1], NULL,
32768, SPLICE_F_MORE | SPLICE_F_MOVE);
assert(ret != -1);
//将管道的输出定向到connfd上
ret = splice(pipefd[0], NULL, connfd, NULL,
32768, SPLICE_F_MORE | SPLICE_F_MOVE);
assert(ret != -1);

从代码中可以看出没有使用到任何缓冲区,因为数据都是在内核实现了传递或者零传递,只是更改缓冲区的指针.

tee函数

tee函数是在两个管道文件描述符之间复制数据,也是零拷贝数据;因为只是从源管道复制到另外管道,所以原管道中保存的数据仍然可以用于后续操作,例如可以重定向到其他文件描述符;函数接口如下:

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

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

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

这个函数的使用场景在我认知范围内,估计就是实现tee命令,可以实现将输入的数据输出到标准输出,同时输出到某个文件.基本使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int fd1 = open("./1.txt", O_RDONLY);
int fd2 = open("./2.txt", O_RDWR| O_CREAT | O_TRUNC, 0666);
int fd3 = open("./3.txt", O_RDWR| O_CREAT | O_TRUNC, 0666);
/*用于向"./2.txt"输入数据*/
int pipefd2[2];
/*用于向"./3.txt"输入数据*/
int pipefd3[2];
pipe(pipefd2);
pipe(pipefd3);
/*将fd1文件的内容输入管道pipefd2中*/
splice(fd1, NULL, pipefd2[1], NULL, 10086, SPLICE_F_MORE);
/*将管道pipefd2的内容复制到管道pipefd3中,不消耗管道pipefd2上的数据,管道pipefd2上的数据可以用于后续操作*/
tee(pipefd2[0], pipefd3[1], 10086, SPLICE_F_NONBLOCK);
/*将管道pipefd2的内容写入fd2文件中*/
splice(pipefd2[0], NULL, fd2, NULL, 10086, SPLICE_F_MORE);
/*将管道pipefd3的内容写入fd3文件中*/
splice(pipefd3[0], NULL, fd3, NULL, 10086, SPLICE_F_MORE);

这个程序是将fd1中的数据重定向到两个磁盘文件,而tee命令是将fd1的数据重定向到fd2和标准输出,即最后一行代码的fd3替换为STDOUT_FILENO.

那有人可能会问,最后一行代码为什么不能pipefd3[0],也就是两次都从管道pipefd2[0]读取数据? 我试着修改pipefd3[0]该pipefd2[0],会造成输出阻塞. 因为splice函数将pipefd2[0]的缓冲区数据消耗了,所以第二次在pipefd2[0]调用splice会因为无数据读入而阻塞.而因为tee不会消耗数据,所以tee函数之后,还可以继续使用splice从相同pipefd2[0]读取数据.

统一事件源


这个是我看这本书最喜欢的设计,因为它打通了我之前在libevent理解上的阻塞.之前在memcache时,有大概看了下libevent的实现原理,因为时间事件和io读写事件和redis的mainae模块一样,很好理解,但是信号事件我始终想不明白是如何加入到事件处理的?这里又不得不表扬管道了,具体实现后面在说明.

服务器常见的有三种事件类型,IO读写事件,信号事件和时间事件,如果分别去监听这三种事件,那么代码将会非常复杂,而将这三种事件整合在一个事件源中,代码将会更加简洁,而且逻辑非常清楚.libevent就是这么做的.所以这里借助libevent来分析如何将三种事件整合在一起,参考资料为张亮的 libevent 源码深度剖析,我上传到我的github,有需要可以下载看libevent源码剖析下载地址

libevent是一个轻量级的开源高性能网络库,底层支持多种I/O多路复用技术,epoll,poll,dev/poll,select和kqueue等,同时还支持定时器和信号事件;先看下libevent的整体框架 libevent整体框架

一开始,应用程序定义事件,可以是io读写事件,信号事件或者时间事件;定义好之后,接下来将事件放在各自的等待链表中;io读写事件和信号事件放在链表中,时间事件放在最小堆中.当io多路复用调用时间超时时,再检查每个事件,看是否就绪,例如可读可写事件是否可读可写,是否产生信号事件以及时间事件是否到期,并将所有就绪的事件放入就绪链表中,最后按优先级执行每个事件的事件处理函数.

了解了libevent执行过程之后,再来分析libevent是如何将三种事件整合在一起的.

  1. IO读写事件 因为libevent底层支持多种IO多路复用技术,以epoll为例,当我们定义个IO事件时,将IO事件的fd可读事件注册到epoll调用中;当客户端给服务器发送数据之后,触发服务器可读事件,服务器读取客户端的数据,并且经过业务逻辑处理之后,将数据放入输出缓冲区,并在fd上注册一个可写事件;在下一次事件循环中,检测到可写事件,最后执行可写事件的处理函数将数据发送给客户端.这就是libevent处理IO事件的过程.
  2. 时间事件 libevent用最小堆来管理时间事件,每次从堆中取出的时间事件是事件到期时间离当前最近的时间点. 如果epoll每次的超时时间都采用堆的根元素到期时间,那么epoll超时时间到期之后,最小堆的根元素事件肯定也到期了,在处理完IO事件之后,即可处理根元素时间事件,而且必须处理诺干个最小到期时间事件,直到到期时间在当前时间之后. 因为IO事件可能比较耗时,所以除了堆顶时间外,其他时间事件可能也到期了.这样就把时间事件和IO事件整合到一块了.
  3. 信号事件 libevent整合信号事件是最出彩的地方, 采用的方法如下:在主线程定义一对管道,然后将管道的读端注册到io多路复用的可读事件;当产生某个信号时,在信号处理函数中,往这对管道的写端写入这个信号的数值,这样即可触发管道读端的可读事件;然后在IO多路复用超时之后,根据不同的信号,执行不同的函数. 这样就可以把信号事件整合在IO事件中.

一开始接触管道的时候,并没有发现管道是如此的神器; 我们在linux下遇到最多的就是磁盘文件描述符,socket描述符和管道描述符,而管道描述符属于最灵活,即可以作为其他两者的连接桥梁,如上述的splice函数,还可以作为进程间通信以及将信号事件整合IO事件中等等.

多进程/多线程并发模式


服务器端编程三大模型,redis的单进程模型,memcache的单进程多线程模型和nginx的多进程模型.由于我看过redis,memcache源码,我对前两者模型很熟悉;而nginx源码我没看过,也没认真区研究nginx,所以对多进程模型也是一知半解; Linux服务器高性能编程 举出一个多进程通用模式,看完之后,我对这种模型有了一定的了解.

多线程并发模式

书本中列出多线程的两种模式,半同步半异步反应堆模式;下图是该模式的示意图: 半同步半异步反应堆模式 该模式的主线程负责监听所有socket,包括listenfd和客户端fd.当客户端给服务器发送数据时,主线程将客户端的fd以及客户端的元数据打包成任务对象放入请求队列,然后工作线程同步的从请求队列中获取任务对象,并且处理任务对象逻辑.因为多个线程会从工作队列中争夺任务对象,所以工作队列必须加锁以保持同步.这种模式的客户端是无状态的,因为同一个连接上的不同请求可能会由不同的线程处理.

高性能半同步半异步模式,这也是memcache采用的多线程模式,下图是该模式的示意图: 高性能半同步半异步模式 该模式的主线程主要负责监听listenfd,当有客户端连接到服务器时,主线程accept这个客户端socket,然后将这个socket放入工作线程的libevent的实例中,从此就由这个libevent实例来负责这个客户端socket的事件处理.

那么怎么通知工作线程什么时候去接收这个socket? 这时又是管道的功劳.每个工作线程都定义一对管道,工作线程监听管道的读端; 当有客户端连接时,主线程向管道的写端写入一个字符,触发工作线程的可写事件,这样工作线程就可以执行管道读端的事件处理函数,在函数中接收这个socket并为这个socket注册一个可读事件,从此以后,这个客户端就由这个工作线程监听处理.

多进程模式

Linux服务器高性能编程 书中实现的多进程模型如下图所示: 多进程编程模型 主进程和工作进程利用管道来进行通信,工作进程注册管道的可读事件; 二者同时监听listanfd,也就是说如果有客户端连接的话,主进程和工作进程都能触发可读事件,也就是惊群现象. 但是主进程并不接收客户端socket,而是通过轮询的方式选择一个工作进程来接收这个客户端,并向这个工作进程管道的写端写入一个标志,触发工作进程的可读事件,在管道读事件的处理函数中,工作进程接收这个客户端. 之后这个客户端与服务器的通信就由这个工作进程负责监听. 所以这种模式的主进程主要起负载均衡作用,所有任务主要是由工作进程负责.

对多进程的理解,必须对fork系统调用以及copy on write机制有足够的理解,因为主进程和工作进程间有共享的部分listenfd也有各自独享部分.

总结


如果想从事后台开发,了解常用的开发框架是很有必要的,因为对于理解后台软件web服务器或者数据库很有帮助.而且我发现多进程比较适合web服务器,多线程比较适合数据库.因为数据库需要在同一块内存中存储数据,由于多线程并发处理内存数据,而多进程的每个进程内存空间是隔离的,每个进程处理数据导致会导致多份数据;而web数据库主要是接收客户端请求并返回网页,每个客户端对网页的请求相互独立,所以适合多进程模型.

这本书对后台知识讲解归纳的很到位,可以对后台开发有个整体的认识,有linux基础之后,推荐读读...