构建高性能站web站点第三章服务器并发处理能力(4)

内存映射

Linux 内核提供一种访问磁盘文件的特殊方式,它可以将内存中某块地址空间和我们要指定的磁盘文件相关联,从而把我们对这块内存的访问转换为对磁盘的访问,这种技术称为内存映射(Memory Mapping)

在大多数情况下,使用内存映射可以提高磁盘I/O的性能,它无须使用read()或write()等系统调用来访问文件,而是通过mmap()系统调用来建立内存和磁盘文件的关联,然后像访问内存一样自由地访问文件。

有两种类型的内存映射,共享型和私有型。前者可以将任何对内存的写操作都同步到磁盘文件,而且所有映射同一个文件进程都共享任意一个进程对映射内存的修改; 后者映射的文件只能是只读文件,所以不可以将对内存的写同步到文件,而且多个进程不共享修改。显然,共享型内存映射的效率偏低,因为如果一个文件被很多进程映射,那么每次的修改同步将药费一定的开销。

Apache 2.x中使用了内存映射,前面我们在探讨系统调用的时候,使用strace跟踪了Apache子进程,当时我们请求的URL指向一个很小的静态文件,只有151字节,Apahce对于较小的静态文件,选择使用内存映射来读取,我位重新来看看其中的地块片段:

accept4(4, {sa_family=AF_INET6, sin6_port=htons(59637), inet_pton(AF_INET6, "::ffff:192.168.1.64", &sin6_addr), sin6_f
lowinfo=0, sin6_scope_id=0}, [28], SOCK_CLOEXEC) = 9
getsockname(9, {sa_family=AF_INET6, sin6_port=htons(80), inet_pton(AF_INET6, "::ffff:192.168.1.3", &sin6_addr), sin6_f
lowinfo=0, sin6_scope_id=0}, [28]) = 0
fcntl(9, F_GETFL)                       = 0x2 (flags O_RDWR)
fcntl(9, F_SETFL, O_RDWR|O_NONBLOCK)    = 0
read(9, "GET /%E5%8D%81%E5%AD%97%E7%BB%A3"..., 8000) = 325
stat("/var/www/html/\345\215\201\345\255\227\347\273\243.mp4", {st_mode=S_IFREG|0644, st_size=22672017, ...}) = 0
open("/var/www/html/\345\215\201\345\255\227\347\273\243.mp4", O_RDONLY|O_CLOEXEC) = 10
mmap(NULL, 4194304, PROT_READ, MAP_SHARED, 10, 0) = 0x7fd64df24000
mmap(NULL, 4194304, PROT_READ, MAP_SHARED, 10, 0x400000) = 0x7fd64db24000
mmap(NULL, 4194304, PROT_READ, MAP_SHARED, 10, 0x800000) = 0x7fd64d724000
mmap(NULL, 4194304, PROT_READ, MAP_SHARED, 10, 0xc00000) = 0x7fd64d324000
mmap(NULL, 4194304, PROT_READ, MAP_SHARED, 10, 0x1000000) = 0x7fd64cf24000
mmap(NULL, 1700497, PROT_READ, MAP_SHARED, 10, 0x1400000) = 0x7fd64cd84000
writev(9, [{"HTTP/1.1 200 OK\r\nDate: Thu, 20 S"..., 304}, {"\0\0\0\30ftypisom\0\0\0\1isomavc1\0\0V\310moov"..., 41943
04}, {"\323k\247<\26\f\265]UWk/\362$\230!\363\375h\371\221[\340\221\200v\256\346\364\215IV"..., 4194304}, {"\325\244\2
67~\265\315\276\227LP\30\336\253Y\307\2560A\367\317\363\341\311\30\321\213\206p\277\313;\334"..., 4194304}, {"\211\256
\326J\341\21\317k\306\375\370\306\365tg\344b\345c\244\3625\253\304M:\336\250\210\222\245J"..., 4194304}, {"\227u\221\2
75~\301\31\323\0058/\306X\243\370y\310{eT\227/%\310\270\230Sf\362[\203\37"..., 4194304}, {"\6\365t\337\374[g\353\270\2
67s\377$\20\365\255n\215\247\304\371\325\370\237\227\312ou\230\224\372\311"..., 1700497}], 7) = 29200
writev(9, [{"\"\373\273)\206:\375-P\2031\367\212\357\321\33\245.J\2\317\26Z\v\23H\372\343\312\316\263&"..., 4165408}, 
{"\323k\247<\26\f\265]UWk/\362$\230!\363\375h\371\221[\340\221\200v\256\346\364\215IV"..., 4194304}, {"\325\244\267~\2
65\315\276\227LP\30\336\253Y\307\2560A\367\317\363\341\311\30\321\213\206p\277\313;\334"..., 4194304}, {"\211\256\326J
\341\21\317k\306\375\370\306\365tg\344b\345c\244\3625\253\304M:\336\250\210\222\245J"..., 4194304}, {"\227u\221\275~\3
01\31\323\0058/\306X\243\370y\310{eT\227/%\310\270\230Sf\362[\203\37"..., 4194304}, {"\6\365t\337\374[g\353\270\267s\3
77$\20\365\255n\215\247\304\371\325\370\237\227\312ou\230\224\372\311"..., 1700497}], 6) = -1 EAGAIN (Resource tempora
rily unavailable)
writev(9, [{"\"\373\273)\206:\375-P\2031\367\212\357\321\33\245.J\2\317\26Z\v\23H\372\343\312\316\263&"..., 4165408}, 
{"\323k\247<\26\f\265]UWk/\362$\230!\363\375h\371\221[\340\221\200v\256\346\364\215IV"..., 4194304}, {"\325\244\267~\2
/munmap
...跳过
writev(9, [{"Q~\371\177\313\321\237\350\257\334\0245\2510\17W\246N\277`\217\225\337}\202M[\337\330!\351M"..., 11708}, 
{"\323k\247<\26\f\265]UWk/\362$\230!\363\375h\371\221[\340\221\200v\256\346\364\215IV"..., 4194304}, {"\325\244\267~\2
65\315\276\227LP\30\336\253Y\307\2560A\367\317\363\341\311\30\321\213\206p\277\313;\334"..., 4194304}, {"\211\256\326J
\341\21\317k\306\375\370\306\365tg\344b\345c\244\3625\253\304M:\336\250\210\222\245J"..., 4194304}, {"\227u\221\275~\3
01\31\323\0058/\306X\243\370y\310{eT\227/%\310\270\230Sf\362[\203\37"..., 4194304}, {"\6\365t\337\374[g\353\270\267s\3
77$\20\365\255n\215\247\304\371\325\370\237\227\312ou\230\224\372\311"..., 1700497}], 6) = 33580
munmap(0x7fd64df24000, 4194304)         = 0

我们访问的文件是/var/www/html/\345\215\201\345\255\227\347\273\243.mp4,Apache使用open()系统调用打开这个文件,获得文件描述符为10,然后通过mmap系统调用完成了共享型内存映射的关联。随后,Apache读取文件中的内容,这个操作并没有体现出系统调用,因为它只是进程读取地址空间数据的用户太行为。接下来,Apache使用writev()系统调用将HTTP响应数据的头信息和数据的正文合并后发送,然后调用munmap()来撤销映射。

直接I/O

在Linux 2.6 中,内存映射和直接访问文件没有本质上差异,因为数据从进程用户态内存空间到磁盘都要经过两次复制,即在磁盘与内核缓冲区之间以及在内核缓冲区与用户态内存空间。

引入内核缓冲区的目的在于提高磁盘文件的访问性能,因为当进程需要读取磁盘文件时,如果文件内容已经在内核缓冲区中,那么就不需要再次访问磁盘;而当进程需要向文件中写入数据时,实际上只是写到了内核缓冲区便告诉进程已经写成功,而真正写入磁盘是通过一定的策略进行延迟的。

然而,对于一些较复杂的应用,比如数据库服务器,它们为了充分提高性能,希望绕过内核缓冲区,由自己在用户太空间实现并管理I/O缓冲区,包括缓存机制和写延迟机制等,以支持独特的查询机制,比如数据库存可以根据更加合理的策略来提高查询缓存命中率。另一方面,绕过内核缓冲区也可以减少系统内存的开销,因为内核缓冲区本身就在使用系统内存。

Linux提供了对这种需求的支持,即在open()系统调用中增加参数选项O_DIRECT,用它打开的文件便可以绕过内核缓冲区的直接访问,这样便有效避免了CPU和内存的多余时间开销。

在MySQL中,对于Innodb存储引擎,其自身可以进行数据和索引的缓存管理,所以它对于内核缓冲区的依赖不是那么重要,MySQL 提供了一种实现直接I/O的方法,在my.cnf配置中,可以在分配Innodb数据空间文件的时候,通过使用raw分区跳过内核缓冲区,实现直接I/O,这在MySQL的官方手册上略有介绍,但是不多,主要涉及raw分区的使用,这是一种特别的分区,它不能像其他分区格式(比如ext2) 一样通过mount来挂载使用,而是需要使用raw设备管理程序来加载。为Innodb使用raw分区的配置如下所示:

innodb_data_file_path = /dev/sda5:100Gnewraw

假设/dev/sda5是raw分区,在分区大小后面增加newraw关键字,便可以将该raw分区作为数据空间,并由Innodb存储引擎直接访问。具体的操作还涉及其他一些步骤,这里就不具体罗列了。

另外,MySQL还提供了innodb_flush_method配置选项,你可以将它设置为如下形式:

innodb_flush_method = O_DIRECT

这样便便可以通过另一种方式来实现直接I/O。

顺便提一下,与O_DIRECT类似的一个选项是O_SYNC,后者只对写数据有效,它将写入内核缓冲区的数据立即写入磁盘,将机器故障时数据丢失减少到最小,但是它仍然要经过内核缓冲区。

sendfile

大多数时候,我们都在向Web服务器请求静态文件,比如图片、样式表等,根据前面的介绍,我们知道在处理这些请求的过程中,磁盘文件的数据要经过内核缓冲区,然后到达用户内存空间,因为是不需要任何处理的静态数据,所以它们又被送到网卡对应的内核缓冲区,接着再被送入网卡进行发送。

数据从内核出去,绕了一圈,又回到内核,没有任何变化,看起来真是浪费时间。Linux 2.4 的内核中,尝试性地引入了一个称为khttpd的内核级Web服务器程序,它只处理静态文件的请求。引入它的目的便在于内核希望请求的处理尽量在内核完成,减少内核态的切换以及用户态数据复制的开销。

同时,Linux通过系统调用将这种机制提供给了开发者,那就是sendfile()系统调用,它可以将磁盘文件的特定部分直接传送到代表客户端的socket描述符,加快了静态文件的请求速度,同时也减少CPU和内存开销。

在OpenBSD和NetBSD中没有提供对sendfile的支持。

还记得在介绍内存映射的时候,我们通过strace的跟踪看到了Apache在处理151字节的小文件时,使用了mmap()系统调用来实现内存映射,但是在Apache处理较大文件的时候,内存映射会导致较大的内存开销,得不偿失,所以Apache使用了sendfile64()来传送文件,sendfile64()是sendfile()的扩展实现,它在Linux 2.4之后的版本中提供。

我们来访问一个3.6MB的文件,用strace跟踪如下:

open("/var/www/html/\345\243\260\345\276\213\345\220\257\350\222\231.mp3", O_RDONLY|O_CLOEXEC) = 10
setsockopt(9, SOL_TCP, TCP_CORK, [1], 4) = 0
writev(9, [{"HTTP/1.1 200 OK\r\nDate: Thu, 20 S"..., 303}], 1) = 303
sendfile(9, 10, [0] => [28897], 3747383) = 28897
setsockopt(9, SOL_TCP, TCP_CORK, [0], 4) = 0
sendfile(9, 10, [28897], 3718486)       = -1 EAGAIN (Resource temporarily unavailable)
read(9, 0x556c05e69348, 8000)           = -1 EAGAIN (Resource temporarily unavailable)
sendfile(9, 10, [28897], 3718486)       = -1 EAGAIN (Resource temporarily unavailable)
poll([{fd=9, events=POLLOUT}], 1, 60000) = 1 ([{fd=9, revents=POLLOUT}])
sendfile(9, 10, [28897] => [52257], 3718486) = 23360
sendfile(9, 10, [52257], 3695126)       = -1 EAGAIN (Resource temporarily unavailable)
poll([{fd=9, events=POLLOUT}], 1, 60000) = 1 ([{fd=9, revents=POLLOUT}])
sendfile(9, 10, [52257] => [77077], 3695126) = 24820
sendfile(9, 10, [77077], 3670306)       = -1 EAGAIN (Resource temporarily unavailable)
poll([{fd=9, events=POLLOUT}], 1, 60000) = 1 ([{fd=9, revents=POLLOUT}])
sendfile(9, 10, [77077] => [101897], 3670306) = 24820
sendfile(9, 10, [101897], 3645486)      = -1 EAGAIN (Resource temporarily unavailable)
poll([{fd=9, events=POLLOUT}], 1, 60000) = 1 ([{fd=9, revents=POLLOUT}])
sendfile(9, 10, [101897] => [131097], 3645486) = 29200
sendfile(9, 10, [131097], 3616286)      = -1 EAGAIN (Resource temporarily unavailable)

这里只列出了我们关注的20个系统调用,可以清楚地看到,Apache使用open()系统调用来打开我们请求的文件,获得的文件描述符为10,然后通过writev()系统调用发送出HTTP响应头,接着对3.6M的正文数据使用sendfile()进行分段发送,其调用参数中包含了两个文件描述符,分别是代表磁盘文件的10和代表客户端的9。

在这种机制下,我们用ab进行压力测试,得到的结果如下所示:

Server Software:        Apache/2.4.6
Server Hostname:        localhost
Server Port:            80

Document Path:          /声律启蒙.mp3
Document Length:        3747383 bytes

Concurrency Level:      100
Time taken for tests:   2.736 seconds
Complete requests:      1000
Failed requests:        0
Write errors:           0
Total transferred:      3747649000 bytes
HTML transferred:       3747383000 bytes
Requests per second:    365.44 [#/sec] (mean)
Time per request:       273.644 [ms] (mean)
Time per request:       2.736 [ms] (mean, across all concurrent requests)
Transfer rate:          1337436.53 [Kbytes/sec] received

sendfile 真的发挥预期的作用了吗?,我们关掉Apache的sendfile,再来试一次。Apache提供了sendfile开关,修改httpd.conf如下所示:

EnableSendfile off

accept4(4, {sa_family=AF_INET6, sin6_port=htons(63724), inet_pton(AF_INET6, "::ffff:192.168.1.64", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28]
, SOCK_CLOEXEC) = 9
getsockname(9, {sa_family=AF_INET6, sin6_port=htons(80), inet_pton(AF_INET6, "::ffff:192.168.1.3", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28]
) = 0
fcntl(9, F_GETFL)                       = 0x2 (flags O_RDWR)
fcntl(9, F_SETFL, O_RDWR|O_NONBLOCK)    = 0
read(9, "GET /%E5%A3%B0%E5%BE%8B%E5%90%AF"..., 8000) = 319
stat("/var/www/html/\345\243\260\345\276\213\345\220\257\350\222\231.mp3", {st_mode=S_IFREG|0644, st_size=3747383, ...}) = 0
open("/var/www/html/\345\243\260\345\276\213\345\220\257\350\222\231.mp3", O_RDONLY|O_CLOEXEC) = 10
brk(NULL)                               = 0x555a4eb7a000
brk(0x555a4eb9c000)                     = 0x555a4eb9c000
lseek(10, 0, SEEK_SET)                  = 0
read(10, "ID3\3\0\0\0\0\r@TCOP\0\0\0\21\0\0\0www.baobao8"..., 8000) = 8000
lseek(10, 8000, SEEK_SET)               = 8000
read(10, "\207\372}\231\332\353s\377\373pd\350\200\1\323\36\307\320A\22P?`\30\251\4#\0\10hg\37"..., 8000) = 8000
lseek(10, 16000, SEEK_SET)              = 16000
read(10, "\27`\242\205\0G\347JuZ\263\334\353\2717\247\377\213'\316\301\272\212\260\n\22Y\220\350\0\0\2"..., 8000) = 8000
lseek(10, 24000, SEEK_SET)              = 24000
read(10, "\362\177P\303\1c\32\360P\3548P\4#n\10\270\317\20\243\204k\201\"\200!\200\21\215\271\245\351"..., 8000) = 8000
lseek(10, 32000, SEEK_SET)              = 32000
read(10, "\243V\340\237o\244\3239\312\355V\255ef\344\316\230\374\24j\336w\35\1\367JY\34\255\226\267M"..., 8000) = 8000
lseek(10, 40000, SEEK_SET)              = 40000
read(10, "\"J\t93\16\245\10K\300\374\17\"\344\20\215(\377\277\365\332\302\374\364\23\3576\245\26\303\32h"..., 8000) = 8000
lseek(10, 48000, SEEK_SET)              = 48000
read(10, "e\373jk\276m\n^\30\271\322AS\316sq\325\17\261\360T<[\2259\216\246\347\265A \4"..., 8000) = 8000
lseek(10, 56000, SEEK_SET)              = 56000
read(10, "0\367\224\346(Eg\263\232\2177\345\24\270\357\344\1\0\231\33\272\2\22\30\26\262\310\334\245KQ\255"..., 8000) = 8000
lseek(10, 64000, SEEK_SET)              = 64000
read(10, "\321s\27\36\343@S\254g\2533\353#\243R?\\V\377\244\0\30\16:m\262\0\3\214\300\205\205"..., 8000) = 8000
lseek(10, 72000, SEEK_SET)              = 72000
read(10, "\314\276\376\177\377\372\201\34xNO\242\201Yv`\222d\343\211\233/[\317}i\374\317\277\251\367V"..., 8000) = 8000
lseek(10, 80000, SEEK_SET)              = 80000
read(10, "\365\252\244Z\202\250!\231\232\251,0\240\330\303\242\253\347\335f\337\240\t\0\7\n\16\330\0\1(\23"..., 8000) = 8000
lseek(10, 88000, SEEK_SET)              = 88000
read(10, "\301o\362\27\377\324\377\177\346D`2\"\307Z@Lq\244\4\202Qo_A\277\257\316\373z\275\373"..., 8000) = 8000
lseek(10, 96000, SEEK_SET)              = 96000
read(10, "L\5\255\323\345\37\323\340U\377\325\377\363?\377\377V\377\377\373\216\234\334B\10\20\274\34\300\16\4-"..., 8000) = 8000
lseek(10, 104000, SEEK_SET)             = 104000
read(10, "\377\352\371\335\357\3655\27\201\300KTD\322b\352\25F\221\366v\203\301\21q\n1\21\0074A\251"..., 8000) = 8000
lseek(10, 112000, SEEK_SET)             = 112000
read(10, "\177\3644\20\233\211\17\354I5]\314dE\1\241\307\327\366\372\371\5\rd\240h)\231\334*\376$"..., 8000) = 8000
writev(9, [{"HTTP/1.1 200 OK\r\nDate: Thu, 20 S"..., 303}, {"ID3\3\0\0\0\0\r@TCOP\0\0\0\21\0\0\0www.baobao8"..., 8000}, {"\207\372}\231\332\353s\377\37
3pd\350\200\1\323\36\307\320A\22P?`\30\251\4#\0\10hg\37"..., 8000}, {"\27`\242\205\0G\347JuZ\263\334\353\2717\247\377\213'\316\301\272\212\260\n\22Y\22
0\350\0\0\2"..., 8000}, {"\362\177P\303\1c\32\360P\3548P\4#n\10\270\317\20\243\204k\201\"\200!\200\21\215\271\245\351"..., 8000}, {"\243V\340\237o\244\
3239\312\355V\255ef\344\316\230\374\24j\336w\35\1\367JY\34\255\226\267M"..., 8000}, {"\"J\t93\16\245\10K\300\374\17\"\344\20\215(\377\277\365\332\302\3
74\364\23\3576\245\26\303\32h"..., 8000}, {"e\373jk\276m\n^\30\271\322AS\316sq\325\17\261\360T<[\2259\216\246\347\265A \4"..., 8000}, {"0\367\224\346(E
g\263\232\2177\345\24\270\357\344\1\0\231\33\272\2\22\30\26\262\310\334\245KQ\255"..., 8000}, {"\321s\27\36\343@S\254g\2533\353#\243R?\\V\377\244\0\30\
16:m\262\0\3\214\300\205\205"..., 8000}, {"\314\276\376\177\377\372\201\34xNO\242\201Yv`\222d\343\211\233/[\317}i\374\317\277\251\367V"..., 8000}, {"\3
65\252\244Z\202\250!\231\232\251,0\240\330\303\242\253\347\335f\337\240\t\0\7\n\16\330\0\1(\23"..., 8000}, {"\301o\362\27\377\324\377\177\346D`2\"\307Z
@Lq\244\4\202Qo_A\277\257\316\373z\275\373"..., 8000}, {"L\5\255\323\345\37\323\340U\377\325\377\363?\377\377V\377\377\373\216\234\334B\10\20\274\34\30
0\16\4-"..., 8000}, {"\377\352\371\335\357\3655\27\201\300KTD\322b\352\25F\221\366v\203\301\21q\n1\21\0074A\251"..., 8000}, {"\177\3644\20\233\211\17\3
54I5]\314dE\1\241\307\327\366\372\371\5\rd\240h)\231\334*\376$"..., 8000}], 16) = 29200

的确没有使用sendfile64(),而是用普通的read()和write()多次复制数据,我们看到,前面sendfile某一次发送了28897字节的数据,而这里的read()和write()一次发送了8000字节的数据,所以需要更多的发送次数,也就是更多的系统调用。

最后来看看性能上的差距,用ab对关闭sendfile的Apache 进行压力测试,结果如下所示:

Server Software:        Apache/2.4.6
Server Hostname:        localhost
Server Port:            80

Document Path:          /声律启蒙.mp3
Document Length:        3747383 bytes

Concurrency Level:      100
Time taken for tests:   4.440 seconds
Complete requests:      1000
Failed requests:        0
Write errors:           0
Total transferred:      3747649000 bytes
HTML transferred:       3747383000 bytes
Requests per second:    225.25 [#/sec] (mean)
Time per request:       443.960 [ms] (mean)
Time per request:       4.440 [ms] (mean, across all concurrent requests)
Transfer rate:          824356.77 [Kbytes/sec] received

相比于前面的365.44 reqs/s,这里的吞吐率实降低了不少。但是,这并不意味着sendfile在任何场景下都能发挥显著的作用。对于请求较小的静态文件,sendfile发挥的作用便显得不那么重要,通过压力测试,我们摸拟100个并发用户请求151字节的静态文件,是否使用sendfile的吞吐率几乎是相同的,可见在处理小文件请求时,发送数据的环节在整个过程中所占时间的比例相比于大文件请求时要小很多,所以对于部分的优化效果自然不十分明显。我们可以用ab来大概了解一下两者的比例差别,其中Processing部分的时间在这里可以理解为发送文件的时间,而Connect则是指建立TCP连接的时间。结果如下所示:

关闭sendfile,

小文件

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    1   2.0      0       8
Processing:     5   19   4.1     20      25
Waiting:        0   19   4.1     20      25
Total:          9   20   3.6     20      29

大文件

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    2   3.0      0      14
Processing:    15  436  80.3    443     572
Waiting:        1  394  67.0    413     455
Total:         24  437  78.6    445     574

开启sendffile

小文件

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    1   1.9      0       8
Processing:     5   19   3.1     21      22
Waiting:        0   19   3.1     21      22
Total:          9   20   2.2     21      27

大文件

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    1   1.9      0       8
Processing:    14  232  38.0    229     324
Waiting:        0  227  38.7    224     322
Total:         18  233  37.0    229     329

结果已经很清楚了,的确,任何一种技术都有它的应用范围,能够意识到环境的不同而选择适合的技术才是明智之举。

异步I/O

说到这里,就得说说同步I/O和异步I/O的区别了。在有些时候,同步和异步、阻塞和非阻塞很容易被混用,其实它们完全不是一回事,而且它们修饰的对象也不同。阻塞和非阻塞是指当进程访问的数据如果尚未就绪,进程是否需要等待,简单说这相当于函数内部的实现区别,即未就绪是是直接返回还是等待就绪;而同步和异步是指访问数据的机制,同步一般指主动请求并等待I/O操作完毕的方式,当数据就绪后在读写的时候必须阻塞,异步则指主动请求数据后便可以继续处理其他任务,随后等待I/O操作完结的通知,这可以使进程在数据读写时也不发生阻塞。

POSIX1003.1标准为异步方式访问文件定义了一套库函数,这里的异步I/O(AIO)实际上就是指当用户态进程调用库函数访问文件时,进行必要的快速注册,比如进入读写操作队列,然后函数马上返回,这时候真正的I/O传输还没有开始呢。

可以看出,这各自机制是真正意义上的异步I/O,而且是非阻塞的,它可以使进程在发起I/O操作后继续运行,让CPU处理和I/O操作达到更好的重叠。

POSIX的标准库定义了AIO的一系列接口,它几乎屏蔽了一切网络通信的细节,所以对使用者而言非常简单。AIO没有提供非阻塞的open()方法,所以进程仍然使用open()系统调用来打开文件,然后填充一些描述I/O请求的数据结构,接下来调用aio_read()或aio_write()来发起异步I/O操作,一旦请求进程操作队列后,函数便返回,进程可以在此后通过aio_error()来检查正在运行的I/O操作的状态。

然而对于AIO的实现,不同的平台有不同的方法,它甚至可以完全由库函数来实现而不需要内核的支持,比如通过多线程来模拟非阻塞的aio_read()调用,但是这样一来,它的性能便大打折扣,变得毫无意义,所以实际上很多平台都没有实现它。

在Linux 2.6.16中,AIO的实现可以在/usr/include/libaio.h中看到,它采用了一套没有遵循POSIX AIO标准的接口,并且实现方式正是基于前面说到的LinuxThreads内核级线程库,截至目前,这个功能还在实现中,目前的Linux AIO只能用于O_DIRECT标志打开的文件,在前面的直接I/O中我们曾经介绍过O_DIRECT,此处不再赘述。

3.7 服务器并发策略

从本质上讲,所有到达服务器的请求都封装在IP包中,位于网卡的接收缓冲区中,这时候Web服务器要做的事情就是不断地读取这些请求,然后进行处理,并将结果写到发磅缓冲区,这其中包含了一系列的I/O操作和CPU计算,而设计一个并发策略的目的,就是让I/O操作和CPU计算尽量重叠进行,一方面要让CPU在I/O等待时不要空闲,另一方面让CPU在I/O调度上尽量花费最少的时间。

还记得游戏“商国时代”吗?我想很多人都跟我一样喜欢它。熟悉它的玩家都知道,制胜的关键在于高速的经济发展,也就是如何让所有的村民把所有时间都合理地应用在采集资源和新修建筑,这就是每个玩家不断研究的并发策略。

下面我们来看几种常见的Web服务器并发策略。

 一个进程处理一个连接,非阻塞I/O

既然一个进程处理一个连接,那么在并发请求同时到达时,服务器必然要准备多个进程来处理请求。 

早期的一种方式是采用fork模式,由主进程负责accept来自客户端的连接,一旦接收连接,便马上fork()一个新的worker进程来处理,处理结束后,这个进程便被销毁。在几年前,我和同事们用C++编写的CGI程序在Apache下运行时,便是采用这种方式。fork()的开销成为影响性能的关键。

另一种方式是prefork模式,这种方式由主进程先创建一定数量的子进程,每个请求由一个子进程来处理,但是每个子进程可以处理多个请求。父进程往往负责管理子进程,根据站点负载来调整子进程的数量,相当于动态维护一个进程池。

对于accept()的方式,有以下两种策略:

  • 主进程使用非阻塞accept()来连接收连接,当建立连接后,主进程将任务分配给空闲的子进程来处理;
  • 所有子进程使用阻塞accept()来竞争接收连接,一旦一个子进程建立连接后,它将继续进行处理。

Apache 2.x便采用上述第2种策略,在这种accept()阻塞竞争的情况下,虽然从代码上看似只有一个进程的accetp()可以返回,但实际上,按大多数TCP栈的实现方法,当一个请求连接到达时,内核会激活所有阻塞在accept()的子进程,但只有一个能够获得连接并返回到用户空间,而其余的子进程由于得不到连接而继续回到休眠状态,这种“抖动”也造成了一定的额外开销。

对于接收HTTP请求数据的I/O操作,Apache采用了非阻塞read(),我们用strace跟踪某子进程从accept()获得连接后直到read()http请求数据的过程,并且统计系统调用消耗的时间。显示在每个系统调用行末的尖括号,结果如下所示:

accept4(4, {sa_family=AF_INET6, sin6_port=htons(55254), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28], SOCK_CLOEXEC) = 9 <17.609598>
getsockname(9, {sa_family=AF_INET6, sin6_port=htons(80), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28]) = 0 <0.000029>
fcntl(9, F_GETFL)                       = 0x2 (flags O_RDWR) <0.000094>
fcntl(9, F_SETFL, O_RDWR|O_NONBLOCK)    = 0 <0.000027>
read(9, "GET /test.html HTTP/1.0\r\nHost: l"..., 8000) = 86 <0.000033>

以上可以看出Apache通过fcntl()系统调用将accept()获得的文件描述符9设置为O_NONBLOCK(非阻塞)模式。

处理守请求后,Apache接着使用poll()来检查当前的socket连接是否有新的请求数据到达,如下所示:

poll([{fd=9, events=POLLIN}], 1, 2000)  = 1 ([{fd=9, revents=POLLIN|POLLHUP}]) <0.000086>
read(9, "", 512)                        = 0 <0.000131>
close(9)                                = 0 <0.000040>

   当客户端关闭socket连接后,poll()的POLLHUP事件会被触发,但有时候会触发POLLIN事件,所以Apache再次调用read()来接收数据,如果发现获得0字节,Apache便认为客户端已经关闭,于是调用close()关闭连接。

如果客户端采用长连接呢?我们跟踪看看,结果如下所示:

                         

               

accept4(4, {sa_family=AF_INET6, sin6_port=htons(55294), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28], SOCK_CLOEXEC) = 9 <66.116793>
getsockname(9, {sa_family=AF_INET6, sin6_port=htons(80), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28]) = 0 <0.000033>
fcntl(9, F_GETFL)                       = 0x2 (flags O_RDWR) <0.000031>
fcntl(9, F_SETFL, O_RDWR|O_NONBLOCK)    = 0 <0.000031>
read(9, "GET /test.html HTTP/1.0\r\nHost: l"..., 8000) = 86 <0.000032>
stat("/var/www/html/test.html", {st_mode=S_IFREG|0644, st_size=41484, ...}) = 0 <0.000040>
open("/var/www/html/test.html", O_RDONLY|O_CLOEXEC) = 10 <0.000056>
setsockopt(9, SOL_TCP, TCP_CORK, [1], 4) = 0 <0.000082>
writev(9, [{"HTTP/1.1 200 OK\r\nDate: Fri, 21 S"..., 276}], 1) = 276 <0.000035>
sendfile(9, 10, [0] => [41484], 41484)  = 41484 <0.000091>
setsockopt(9, SOL_TCP, TCP_CORK, [0], 4) = 0 <0.000075>
write(7, "::1 - - [21/Sep/2018:16:50:39 +0"..., 95) = 95 <0.000057>
close(10)                               = 0 <0.000037>
shutdown(9, SHUT_WR)                    = 0 <0.000111>
poll([{fd=9, events=POLLIN}], 1, 2000)  = 1 ([{fd=9, revents=POLLIN|POLLHUP}]) <0.000087>
read(9, "", 512)                        = 0 <0.000032>
close(9)                                = 0 <0.000040>
read(5, 0x7ffdcd783e9f, 1)              = -1 EAGAIN (Resource temporarily unavailable) <0.000077>
accept4(4, {sa_family=AF_INET6, sin6_port=htons(55298), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28], SOCK_CLOEXEC) = 9 <17.714643>
getsockname(9, {sa_family=AF_INET6, sin6_port=htons(80), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28]) = 0 <0.000025>
fcntl(9, F_GETFL)                       = 0x2 (flags O_RDWR) <0.000026>
fcntl(9, F_SETFL, O_RDWR|O_NONBLOCK)    = 0 <0.000022>
read(9, "GET /test.html HTTP/1.0\r\nConnect"..., 8000) = 110 <0.000023>
stat("/var/www/html/test.html", {st_mode=S_IFREG|0644, st_size=41484, ...}) = 0 <0.000041>
open("/var/www/html/test.html", O_RDONLY|O_CLOEXEC) = 10 <0.000029>
setsockopt(9, SOL_TCP, TCP_CORK, [1], 4) = 0 <0.000062>
writev(9, [{"HTTP/1.1 200 OK\r\nDate: Fri, 21 S"..., 313}], 1) = 313 <0.000030>
sendfile(9, 10, [0] => [41484], 41484)  = 41484 <0.000072>
setsockopt(9, SOL_TCP, TCP_CORK, [0], 4) = 0 <0.000041>
write(7, "::1 - - [21/Sep/2018:16:50:57 +0"..., 95) = 95 <0.000095>
close(10)                               = 0 <0.000047>
read(9, 0x55b3ae881188, 8000)           = -1 EAGAIN (Resource temporarily unavailable) <0.000022>
poll([{fd=9, events=POLLIN}], 1, 5000)  = 1 ([{fd=9, revents=POLLIN}]) <0.000280>
read(9, "", 8000)                       = 0 <0.000025>
shutdown(9, SHUT_WR)                    = 0 <0.000055>
poll([{fd=9, events=POLLIN}], 1, 2000)  = 1 ([{fd=9, revents=POLLIN|POLLHUP}]) <0.000027>
read(9, "", 512)                        = 0 <0.000024>
close(9)                                = 0 <0.000031>
read(5, 0x7ffdcd783e9f, 1)              = -1 EAGAIN (Resource temporarily unavailable) <0.000024>

          实际上,通过前面的内容,我们知道Apache这种多进程模型的开销限制了它的并发连接数,但是Apache也有自身的优势,比如从稳定性和兼容性的角度看,多进程模型的优势正体现在它相对的安全独立进程,任何一个子进程的崩溃都不会影响Apache本身,Apache父进程可以创建新的子进程;另一方面,Apache毕竟经过长期的考验和广泛的使用,它的功能模块非常丰富,比如各种动态脚本的支持、虚拟主机管理、URL Rewrite,SSL加密、SSI(服务器静态网页包含)、目录浏览和管理等,而且安装和配置都相当简单,有大量的官方文档可以参考。所以,对于一些并发数要求不高(如150以内)的站点,如果同时其他功能有所依赖,那么Apache便是非常不错的选择。

一个进程处理多个连接,非阻塞I/O

一个进程处理多个连接,存在一个潜在条件,就是多路I/O就绪通知的应用,在前面的I/O模型中我们介绍了常见的几种多路I/O就绪通知方法,而在这种并发策略下,多路I/O就绪通知的性能成为关键,下面我们会将它们应用在并发模型中并进行深入探讨。

通常我们将处理多个连接的进程称为worker进程,或者服务进程,有些使用这种并发模型的Web服务器支持worker进程数量的配置,比如在Nginx中可以进行配置,如下所示:

worker_processes 2;

这样使得Nginx开启两个worker进程。

root     21452     1  0 14:25 ?        00:00:00 nginx: master process /usr/sbin/nginx
nginx    21453 21452  0 14:25 ?        00:00:00 nginx: worker process
nginx    21454 21452  0 14:25 ?        00:00:00 nginx: worker process

  lighttpd也可以配置worker进程的数量,如下所示:

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值