簡(jiǎn)明網(wǎng)絡(luò)I/O與并發(fā) --- I/O
簡(jiǎn)明網(wǎng)絡(luò)I/O與并發(fā) --- 并發(fā)
計(jì)算機(jī)的基本組成其實(shí)很簡(jiǎn)單,處理器,存儲(chǔ)器加上輸入輸出設(shè)備,就能構(gòu)成計(jì)算機(jī)。大至超級(jí)計(jì)算機(jī),小到手機(jī)等都是一樣的模型。計(jì)算的本質(zhì)就是從輸入設(shè)備讀取數(shù)據(jù)處理然后輸出。可以理解理解計(jì)算機(jī)做的事情就是IO和計(jì)算。
在網(wǎng)絡(luò)發(fā)明之前,計(jì)算機(jī)從存儲(chǔ)設(shè)備中讀取數(shù)據(jù),進(jìn)程通過內(nèi)存的通道進(jìn)行通信。互聯(lián)網(wǎng)誕生之后,越來越多計(jì)算機(jī)通過互聯(lián)網(wǎng)連接,將數(shù)據(jù)傳輸?shù)绞澜绺鞯亍S?jì)算機(jī)之間可以通信,本質(zhì)上也是計(jì)算機(jī)進(jìn)程相互通信。為了方便不同終端進(jìn)行通信,網(wǎng)絡(luò)協(xié)議棧抽象出socket層,通過對(duì)socket文件描述符的操作來進(jìn)行網(wǎng)絡(luò)IO。當(dāng)然,不同的應(yīng)用場(chǎng)景,衍生出了不同的網(wǎng)絡(luò)模型。
本文的描述發(fā)起IO進(jìn)程,也可以描述為發(fā)起IO的線程
一次網(wǎng)絡(luò)響應(yīng)
互聯(lián)網(wǎng)應(yīng)用中,多數(shù)架構(gòu)是CS模式,即client發(fā)出請(qǐng)求,server接受請(qǐng)求,處理之后返回響應(yīng)。這樣的一次交互,伴隨著client和server的IO操作。對(duì)于常見的爬蟲,client將盡可能提升其并發(fā)發(fā)送請(qǐng)求IO的能力,對(duì)于角色類似被爬蟲對(duì)象那些后端server,也需要盡可能提升其并發(fā)處理多client請(qǐng)求的能力。
例如有個(gè)用戶,發(fā)送了一個(gè)請(qǐng)求,請(qǐng)求了服務(wù)器上的一個(gè)文件,這樣的一個(gè)交互過程如圖:
左邊的圖使用了 python3 -m http.server
創(chuàng)建了一個(gè)監(jiān)聽8000的server,后端的client請(qǐng)求了服務(wù)器path為/hello.txt
的文件。具體是如何實(shí)現(xiàn)的呢?當(dāng)server開啟服務(wù)之后,就會(huì)監(jiān)聽來自8000端口請(qǐng)求,client把請(qǐng)求發(fā)送給server,server再從自己的磁盤上讀取 hello.txt 文件,然后返回給客戶端。這樣一次簡(jiǎn)單的交互,涉及了網(wǎng)絡(luò)IO和磁盤文件的IO。大致流程如下圖:
上圖只表述了server處理響應(yīng)的過程:
- server的進(jìn)程發(fā)起Read系統(tǒng)調(diào)用,內(nèi)核隨即從硬件Disk讀取數(shù)據(jù)到內(nèi)核緩沖區(qū)(kernel buf)
- 內(nèi)核再把緩沖區(qū)的數(shù)據(jù)copy到應(yīng)用程序進(jìn)程的緩沖區(qū),應(yīng)用程序就可以對(duì)數(shù)據(jù)進(jìn)行修改。
- 應(yīng)用進(jìn)程將數(shù)據(jù)通過系統(tǒng)調(diào)用Send發(fā)送到socket緩沖區(qū),每個(gè)socket文件都在內(nèi)核維護(hù)了一個(gè)發(fā)送/接受緩沖區(qū)。
- 最后再把socket發(fā)送緩沖區(qū)的數(shù)據(jù)copy到NIC網(wǎng)卡中,通過協(xié)議棧發(fā)送到對(duì)端的網(wǎng)卡中。
- 對(duì)端的網(wǎng)卡接收數(shù)據(jù)中,client也會(huì)發(fā)起一個(gè)Recv的系統(tǒng)調(diào)用,然后內(nèi)核會(huì)從網(wǎng)卡中讀取數(shù)據(jù),然后copy到應(yīng)用程序的緩沖區(qū)。
整個(gè)過程,數(shù)據(jù)在三個(gè)主要層次流動(dòng),即硬件,內(nèi)核,應(yīng)用。在流動(dòng)的過程中,從一個(gè)層流向另一個(gè)層即為IO操作。
DMA Direct Memory Access,直接內(nèi)存訪問方式,即現(xiàn)在的計(jì)算機(jī)硬件設(shè)備,可以獨(dú)立地直接讀寫系統(tǒng)內(nèi)存,而不需CPU完全介入處理。也就是數(shù)據(jù)從DISK或者NIC從把數(shù)據(jù)copy到內(nèi)核buf,不需要計(jì)算機(jī)cpu的參與,而是通過設(shè)備上的芯片(cpu)參與。對(duì)于內(nèi)核來說,這樣的數(shù)據(jù)讀取過程中,cpu可以做別的事情。
一次I/O過程
通過上面的數(shù)據(jù)流動(dòng),可以看到IO的基本方式,那么什么是IO呢?通常現(xiàn)代的程序軟件都運(yùn)行在內(nèi)存里,內(nèi)存又分為用戶態(tài)和內(nèi)核態(tài),后者隸屬于操作系統(tǒng)。所謂的IO,就是將硬件(磁盤、網(wǎng)卡)的數(shù)據(jù)讀取到程序的內(nèi)存中。
因?yàn)閼?yīng)用程序很少可以直接和硬件交互,因此操作系統(tǒng)作為兩者的橋梁。通常操作系統(tǒng)在對(duì)接兩端(應(yīng)用程序與硬件)時(shí),自身有一個(gè)內(nèi)核buf,用于數(shù)據(jù)的copy中轉(zhuǎn)。
應(yīng)用的讀IO操作,即將網(wǎng)卡的數(shù)據(jù),copy到應(yīng)用的進(jìn)程buf,中途會(huì)經(jīng)過內(nèi)核的buf。
- 應(yīng)用進(jìn)行發(fā)起read系統(tǒng)調(diào)用。
- 內(nèi)核接受應(yīng)用的請(qǐng)求,如果內(nèi)核buf有數(shù)據(jù),則把數(shù)據(jù)copy到應(yīng)用buf中,調(diào)用結(jié)束。
- 如果內(nèi)核buf中沒有數(shù)據(jù),會(huì)向io模塊發(fā)送請(qǐng)求,io模塊和硬件交互。
4.當(dāng)NIC接收到協(xié)議棧的數(shù)據(jù)后, NIC 會(huì)通過DMA 技術(shù)將數(shù)據(jù)copy到內(nèi)核 buf 中 - 內(nèi)核將內(nèi)核buf的數(shù)據(jù)copy到應(yīng)用的buf中,調(diào)用結(jié)束。
一般網(wǎng)絡(luò)IO分為兩個(gè)階段,等待數(shù)據(jù)階段和拷貝數(shù)據(jù)階段。前者是指數(shù)據(jù)通過協(xié)議棧發(fā)送到網(wǎng)卡,網(wǎng)卡再通過DMAcopy到內(nèi)核buf。后者是將內(nèi)核buf的數(shù)據(jù)copy到進(jìn)程buf中。
數(shù)據(jù)發(fā)送到nic設(shè)備的過程由協(xié)議棧支持,在操作系統(tǒng)層面實(shí)現(xiàn)。對(duì)于第二階段拷貝數(shù)據(jù)的過程,進(jìn)程的行為很重要,如果進(jìn)程阻塞,那么將是同步調(diào)用,否則則是異步調(diào)用。后面會(huì)再說明。
I/O 基本模型
《UNIX網(wǎng)絡(luò)編程》中提到了5中基本的網(wǎng)絡(luò)I/O模型,主要分為同步和異步I/O:
- 阻塞I/O(blocking)
- 非阻塞I/O(nonblocking)
- 多路復(fù)用I/O(multiplexing)
- 信號(hào)驅(qū)動(dòng)I/O(SIGIO)
- 異步I/O(asynchronous)
好用的是第一種,代碼邏輯簡(jiǎn)單,符合人的思考方式。現(xiàn)實(shí)中常用的是第三種,第二種不太好用,第四種也很少,第五種不太成熟。下面針對(duì)具體的方式逐一簡(jiǎn)介。
阻塞I/O
前面已經(jīng)介紹,IO過程分為兩個(gè)階段,等待數(shù)據(jù)準(zhǔn)備和數(shù)據(jù)拷貝過程。這里涉及兩個(gè)對(duì)象,其一是發(fā)起IO操作的進(jìn)程(線程),其二是內(nèi)核對(duì)象。所謂阻塞是指進(jìn)程在兩個(gè)階段都阻塞,即線程掛起,不能做別的事情。
紅色的虛線表示io函數(shù)調(diào)用過程,加粗的紅線表示數(shù)據(jù)從內(nèi)核buf拷貝到應(yīng)用buf的過程,改過程阻塞線程。簡(jiǎn)書把圖片壓縮得不要看了
進(jìn)程對(duì)象發(fā)起 Recv操作,這是一個(gè)系統(tǒng)調(diào)用,然后內(nèi)核會(huì)看內(nèi)核buf是否有數(shù)據(jù),如果沒有數(shù)據(jù),那么進(jìn)程將會(huì)被掛起,直到內(nèi)核buf從硬件或者網(wǎng)絡(luò)讀取到數(shù)據(jù)之后,內(nèi)核再把數(shù)據(jù)從內(nèi)核buf拷貝到進(jìn)程buf中,然后喚醒發(fā)起調(diào)用的進(jìn)程,并且Recv操作將會(huì)返回?cái)?shù)據(jù)。接下來進(jìn)行可以對(duì)進(jìn)程buf的數(shù)據(jù)進(jìn)行處理。
一個(gè)單線程同步阻塞server:
import socket
address = ('', 5000)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(address)
sock.listen(100)
while True:
conn, addr = sock.accept()
print('client {} connect'.format(conn.fileno()))
while True:
data = conn.recv(1024)
if not data:
print('client {} closed'.format(conn.fileno()))
break
else:
print('data is ', data)
conn.sendall(data)
conn.close()
break
conn.close()
accept 和 recv 兩個(gè)socket上的函數(shù)調(diào)用之后,如果沒有數(shù)據(jù)返回,線程將會(huì)阻塞,直到有數(shù)據(jù)到達(dá)。
server的socket套接字有兩種,一種是 監(jiān)聽套接字 (sock),它有一個(gè)accept方法,該方法的作用就是從已握手的隊(duì)列中取出一個(gè)連接,另外一種是連接套接字(conn),即accept方法返回的socket。
非阻塞I/O
線程在blockingIO中,發(fā)起了IO調(diào)用之后隨即被掛起,不能做別的。在nonblockingIO中,如果沒有io數(shù)據(jù),那么發(fā)起的系統(tǒng)調(diào)用也會(huì)馬上返回,會(huì)返回一個(gè)EWOULDBLOCK
錯(cuò)誤。函數(shù)返回之后,線程沒有被掛起,當(dāng)然是可以繼續(xù)做別的。
正如圖上所示,在真實(shí)環(huán)境中,進(jìn)程發(fā)起了非阻塞io請(qǐng)求,返回了EWOULDBLOCK之后,將會(huì)繼續(xù)再次發(fā)起非阻塞的io請(qǐng)求,這個(gè)過程還是會(huì)使用CPU,因此也稱之為輪詢(polling)。當(dāng)內(nèi)核有數(shù)據(jù)的時(shí)候,內(nèi)核將內(nèi)核buf的數(shù)據(jù)copy到應(yīng)用buf的過程還是需要cpu參與,這個(gè)過程對(duì)于nonblockingio來說,線程仍然是阻塞的。
非阻塞IO的一個(gè)簡(jiǎn)單應(yīng)用:
import socket
import errno
address = ('', 5000)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setblocking(False)
sock.bind(address)
sock.listen(100)
while True:
try:
conn, addr = sock.accept()
except socket.error as e:
if e.args[0] in (errno.EWOULDBLOCK, errno.EAGAIN):
continue
else:
raise
print('client {} connect'.format(conn.fileno()))
conn.setblocking(False)
while True:
try:
data = conn.recv(1024)
except socket.error as e:
if e.args[0] in (errno.EWOULDBLOCK, errno.EAGAIN):
continue
else:
raise
if not data:
print('client {} closed'.format(conn.fileno()))
break
else:
print('data is ', data)
conn.sendall(data)
conn.close()
break
conn.close()
由此可見,當(dāng)數(shù)據(jù)尚未準(zhǔn)備好的時(shí)候,CPU將會(huì)在不同的輪詢。accept和recv都在循環(huán)中。
多路復(fù)用I/O
阻塞IO會(huì)讓線程掛起不能做別的,非阻塞IO則提供了新的思路,函數(shù)調(diào)用之后就返回,可是為了完成IO,需要不同的polling。每次輪詢都是一次系統(tǒng)調(diào)用。某種程度下,非阻塞IO的性能將還不如阻塞IO。既然需要內(nèi)核頻繁操作,那么就有人想出了新的模型。
讓內(nèi)核代理去做輪詢,然后應(yīng)用進(jìn)程只有數(shù)據(jù)準(zhǔn)備了再發(fā)起IO操作不就好了嗎?的確,多路復(fù)用IO就是這樣的原理。由內(nèi)核負(fù)責(zé)監(jiān)控應(yīng)用指定的socket文件描述符,當(dāng)socket準(zhǔn)備好數(shù)據(jù)(可讀,可寫,異常)的時(shí)候,通知應(yīng)用進(jìn)程。準(zhǔn)備好數(shù)據(jù)是一個(gè)事件,當(dāng)事件發(fā)生的時(shí)候,通知應(yīng)用進(jìn)程,而應(yīng)用進(jìn)程可以根據(jù)事件事先注冊(cè)回調(diào)函數(shù)。
進(jìn)程發(fā)起了 select或poll或者epoll調(diào)用之后,可以設(shè)置阻塞進(jìn)程。當(dāng)內(nèi)核數(shù)據(jù)準(zhǔn)備好的時(shí)候通知應(yīng)用進(jìn)程,即事件發(fā)生。應(yīng)用進(jìn)程注冊(cè)了回調(diào)函數(shù),這里是 recv回調(diào)函數(shù)。因此進(jìn)程可以再次發(fā)起recv系統(tǒng)調(diào)用。后面這個(gè)過程前面的阻塞非阻塞調(diào)用一樣。只不過這里通常一定是可以讀到數(shù)據(jù),非阻塞的方式也不會(huì)返回錯(cuò)誤。但是整個(gè)copy過程,進(jìn)程還是阻塞的。
對(duì)于單個(gè)io請(qǐng)求,這樣的做法其實(shí)并沒有多大優(yōu)勢(shì),甚至還不如阻塞IO。不過多路復(fù)用的好處在于多路,即可以同時(shí)監(jiān)聽多個(gè)socket描述符,當(dāng)大量描述符可讀可寫事件發(fā)生的時(shí)候,更有利于服務(wù)器的并發(fā)性能。
多路復(fù)用I/O的本質(zhì)就是
多路監(jiān)聽 + 阻塞/非阻塞IO
。多路監(jiān)聽即select,poll,epoll這些系統(tǒng)調(diào)用。后面的才是真正的IO,紅色的線表示,即前文介紹的阻塞或者非阻塞IO。
下面是一個(gè)poll的例子:
import functools
import select
import socket
class Server:
def __init__(self):
self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._poll = select.poll()
self._handlers = {}
self._fd_events = {}
self._bytes_received = {}
self._bytes_to_send = {}
def start(self):
sock = self._sock
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setblocking(False)
sock.bind(('', 5000))
sock.listen(100)
handlers = self._handlers
poll = self._poll
self.add_handler(sock.fileno(), self._accept, select.POLLIN)
while True:
poll_events = poll.poll(1)
for fd, event in poll_events:
if event & (select.POLLHUP | select.POLLERR | select.POLLNVAL):
rb = self._bytes_received.pop(fd, b'')
sb = self._bytes_to_send.pop(fd, b'')
if rb:
print(f'Client {fd} sent {rb} but then closed')
elif sb:
print(f'Client {fd} closed before we sent {sb}')
else:
print(f'Client {fd} closed socket normally')
self.unregister(fd)
else:
handler = handlers.get(fd)
if handler:
handler()
def _accept(self, ):
while True:
try:
conn, addr = self._sock.accept()
except OSError:
break
else:
conn.setblocking(0)
fd = conn.fileno()
self.add_handler(fd, functools.partial(self._read, conn), select.POLLIN)
def _read(self, conn):
fd = conn.fileno()
more_data = conn.recv(10)
if not more_data:
print('client close')
conn.close()
return
data = self._bytes_received.pop(conn.fileno(), b'') + more_data
if data.endswith(b'\r\n\r\n'):
self._bytes_to_send[conn.fileno()] = data
self.remove_handler(fd)
self.add_handler(fd, functools.partial(self._write, conn, data), select.POLLOUT)
else:
self._bytes_received[conn.fileno()] = data
def _write(self, conn, data):
fd = conn.fileno()
self.remove_handler(fd)
data = self._bytes_to_send.pop(conn.fileno())
n = conn.send(data)
if n < len(data):
self._bytes_to_send[conn.fileno()] = data[n:]
self.add_handler(fd, functools.partial(self._write, conn, data), select.POLLOUT)
else:
conn.close()
def add_handler(self, fd, handler, event):
self._handlers[fd] = handler
self.register(fd, event)
def remove_handler(self, fd):
self._handlers.pop(fd, None)
self.unregister(fd)
def register(self, fd, event):
if fd in self._fd_events:
raise IOError(f"fd {fd} already registered")
self._poll.register(fd, event)
self._fd_events[fd] = event
def unregister(self, fd):
event = self._fd_events.pop(fd, None)
if event is not None:
self._poll.unregister(fd)
if __name__ == '__main__':
server = Server()
server.start()
從代碼邏輯來看,poll_events = poll.poll(1)
調(diào)用設(shè)置超時(shí)時(shí)間,如果不設(shè)置,那么將會(huì)阻塞。當(dāng)描述符數(shù)據(jù)準(zhǔn)備好的時(shí)候,poll會(huì)返回fd和event的組合。例如監(jiān)聽socket綁定的回調(diào)是_accept,而在_accept函數(shù)中,又綁定了連接套接字的_read函數(shù),當(dāng)可讀的時(shí)候調(diào)用了_read進(jìn)行完成之后,又會(huì)綁定其_write函數(shù)。
上述代碼每次recv10字節(jié)數(shù)據(jù),顯然沒有讀完,因此_read函數(shù)返回之后,下一次事件循環(huán),poll將還會(huì)返回連接套接字可讀事件,后者將會(huì)再次調(diào)用_read函數(shù)。直到讀取完所有數(shù)據(jù)。這種編碼方式跟同步阻塞的方式相差挺大的,各種回調(diào)函數(shù)的設(shè)置,讓這個(gè)代碼結(jié)構(gòu)不那么順序化。
當(dāng)然 select poll epoll更多時(shí)候是配合非阻塞的方式使用。如下圖:
一般多路復(fù)用IO都是配合非阻塞IO使用。因?yàn)樽x寫socket的時(shí)候,并不確定讀到什么時(shí)候才能讀完。在一個(gè)循環(huán)里讀,如果設(shè)置為阻塞模式,那么進(jìn)程將會(huì)被掛起。比較好的做法是設(shè)置成非阻塞,一旦讀寫返回了EWOULDBLOCK,進(jìn)行yield,然后切換到別的地方。直到下一次事件循環(huán)。下面是借助python的yield實(shí)現(xiàn)的一個(gè)簡(jiǎn)單的case:
import collections
import socket
import select
import types
import errno
class Stream:
def __init__(self, sock, loop):
sock.setblocking(False)
self._sock = sock
self._loop = loop
def close(self):
self._sock.close()
def read(self, size=10):
while True:
sock = self._sock
fd = sock.fileno()
try:
more_data = sock.recv(size)
print('more data', self, more_data)
except OSError as e:
if e.args[0] not in (errno.EAGAIN, errno.EWOULDBLOCK):
raise
else:
yield
else:
data = self._loop._bytes_received.get(fd, b'') + more_data
if data.endswith(b'\r\n\r\n'):
self._loop.remove_handler(fd)
return data
else:
self._loop._bytes_received[fd] = data
yield
def write(self, data):
sock = self._sock
fd = sock.fileno()
try:
while True:
try:
send_bytes = sock.send(data)
except OSError as e:
if e.errno not in (socket.EWOULDBLOCK, socket.EAGAIN):
raise
else:
yield
else:
if send_bytes == len(data):
return
data = data[send_bytes:]
self._loop.add_handler(fd, self.write(data), select.POLLOUT)
yield
finally:
self._loop.remove_handler(fd)
class IOLoop:
def __init__(self):
self._poll = select.poll()
self._handlers = {}
self._fd_events = {}
self._bytes_received = {}
self._bytes_to_send = {}
def start(self):
handlers = self._handlers
poll = self._poll
while True:
poll_events = poll.poll(1)
for fd, event in poll_events:
# 錯(cuò)誤處理
if event & (select.POLLHUP | select.POLLERR | select.POLLNVAL):
rb = self._bytes_received.pop(fd, b'')
sb = self._bytes_to_send.pop(fd, b'')
if rb:
print(f'Client {fd} sent {rb} but then closed')
elif sb:
print(f'Client {fd} closed before we sent {sb}')
else:
print(f'Client {fd} closed socket normally')
self.unregister(fd)
else:
handler = handlers.get(fd)
if handler:
if callable(handler):
handler()
else:
stack = handler
while True:
generator, value = stack[-1]
try:
yield_value = generator.send(value)
if isinstance(yield_value, types.GeneratorType):
stack.append([yield_value, None])
else:
break
except StopIteration as e:
stack.pop()
if stack:
stack[-1][-1] = e.value
else:
break
def add_handler(self, fd, handler, event):
if isinstance(handler, types.GeneratorType):
self._handlers[fd] = collections.deque([[handler, None]])
else:
self._handlers[fd] = handler
self.register(fd, event)
def remove_handler(self, fd):
self._handlers.pop(fd, None)
self.unregister(fd)
def register(self, fd, event):
if fd in self._fd_events:
raise IOError(f'fd {fd} already registered')
self._poll.register(fd, event)
self._fd_events[fd] = event
def unregister(self, fd):
event = self._fd_events.pop(fd, None)
if event is not None:
self._poll.unregister(fd)
def modify(self, fd, event):
self._poll.modify(fd, event)
self._fd_events[fd] = event
class Server:
def __init__(self):
self._sock = socket.socket()
self._loop = IOLoop()
self._stream = Stream(self._sock, self._loop)
def start(self):
sock = self._sock
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.setblocking(True)
sock.bind(('', 5000))
sock.listen(100)
self._loop.add_handler(sock.fileno(), self._accept, select.POLLIN)
self._loop.start()
def _accept(self):
while True:
try:
conn, addr = self._sock.accept()
except OSError:
break
else:
stream = Stream(conn, self._loop)
fd = conn.fileno()
self._loop.add_handler(fd, self._handle(stream), select.POLLIN)
def _handle(self, stream):
data = yield stream.read()
yield stream.write(data)
if __name__ == '__main__':
s = Server()
s.start()
在 read 方法中,如果 recv 返回了EWOULDBLOCK,那么進(jìn)程將會(huì)yield,然后跳轉(zhuǎn)到別的邏輯進(jìn)行執(zhí)行,直到下一次事件循環(huán)可讀的時(shí)候,再從yield掛起除繼續(xù)執(zhí)行,繼續(xù)調(diào)用recv。與前一個(gè)例子相比,后者在read的代碼邏輯和阻塞式IO更像。當(dāng)然,借助yield黑魔法,還可以將異步回調(diào)代碼寫得跟同步一樣,這就是協(xié)程的編碼方式。python3.5+則通過語言層面實(shí)現(xiàn)了協(xié)程的支持。
多路復(fù)用IO幾乎成為了主流的server方式。尤其是epoll,成為了Nginx,Redis, Tornado等軟件高性能的基石。
信號(hào)IO
讓內(nèi)核在描述符就緒時(shí)發(fā)送SIGIO信號(hào)通知進(jìn)程。這種模型為信號(hào)驅(qū)動(dòng)式I/O(signal-driven I/O),和事件驅(qū)動(dòng)類似,也是一種回調(diào)方式。與非阻塞方式不一樣的地方是,發(fā)起了信號(hào)驅(qū)動(dòng)的系統(tǒng)調(diào)用,進(jìn)程沒有掛起,可以做的事情,可是實(shí)際中,代碼邏輯通常還是主循環(huán),主循環(huán)里可能還是會(huì)阻塞。因此使用這樣的IO的軟件很少。
可是當(dāng)信號(hào)返回可以讀寫的時(shí)候,因?yàn)檫€需要cpu將內(nèi)核數(shù)據(jù)copy到應(yīng)用buf。這個(gè)過程毫無疑問還是阻塞的。
異步I/O
前面一直強(qiáng)調(diào),內(nèi)核在copy數(shù)據(jù)從內(nèi)核buf到應(yīng)用buf的過程中,cpu需要參與,進(jìn)程都會(huì)被阻塞。因此可以理解,進(jìn)程和內(nèi)核的步調(diào)是一致,也就是同步。這樣的IO模型統(tǒng)稱之為同步I/O。那么什么是異步I/O呢?
Unix下的異步I/O模型如下:
圖中的IO調(diào)用函數(shù)的紅線只出現(xiàn)在第一步中。
即無論是第一階段數(shù)據(jù)準(zhǔn)備還是第二階段數(shù)據(jù)拷貝,發(fā)起系統(tǒng)調(diào)用的進(jìn)程都不會(huì)被阻塞。在第二階段的過程中,進(jìn)程沒有阻塞,那么可以搶占CPU,而內(nèi)核copy數(shù)據(jù)的時(shí)候,也需要CPU,這就造成了應(yīng)用和內(nèi)核進(jìn)行CPU競(jìng)爭(zhēng),并且步調(diào)不一致了。某些情況下,其性能反而不如其他IO模式。使用的人也很少。
并發(fā)
前面介紹的IO模型,列舉了server使用的幾個(gè)方式。從代碼結(jié)構(gòu)來看,這樣的server都是單進(jìn)程的。現(xiàn)實(shí)中為了實(shí)現(xiàn)并發(fā)技術(shù),有的程序也會(huì)借助多線程多進(jìn)程方式。關(guān)于更多的服務(wù)器并發(fā)處理模型,已經(jīng)如何使用poll/epoll將會(huì)在后面的文檔中介紹。
多路復(fù)用IO不僅配合非阻塞IO使用,很多時(shí)候也配合單進(jìn)程單線程使用協(xié)程的方式,避免的線程進(jìn)程的上下文切換帶來的性能折損。
總結(jié)
隨著服務(wù)產(chǎn)品用戶流量和用戶群擴(kuò)大,文中梳理了unix常見的幾種網(wǎng)絡(luò)IO,并且針對(duì)這些IO進(jìn)行了簡(jiǎn)單的介紹和簡(jiǎn)要的使用說明。
首先說了什么是IO,即 應(yīng)用內(nèi)存--內(nèi)核緩存--硬件數(shù)據(jù) 三者之間的數(shù)據(jù)流動(dòng)。三者正好組成了兩個(gè)階段,以讀為例子,數(shù)據(jù)從硬件到內(nèi)核buf過程為數(shù)據(jù)準(zhǔn)備狀態(tài),由內(nèi)核拷貝到應(yīng)用為數(shù)據(jù)復(fù)制階段。
在進(jìn)程發(fā)起IO請(qǐng)求時(shí),在第一階段數(shù)據(jù)等待時(shí)是否掛起分為阻塞和非阻塞
數(shù)據(jù)等待階段
- 阻塞:進(jìn)程掛起
- 非阻塞:進(jìn)程不掛起,立即返回,返回EWOULDBLOCK
在第二個(gè)階段數(shù)據(jù)拷貝過程時(shí),發(fā)起IO請(qǐng)求的進(jìn)程是否阻塞。
數(shù)據(jù)拷貝階段:
- 阻塞進(jìn)程:同步,內(nèi)核拷貝數(shù)據(jù)占用CPU
- 不阻塞進(jìn)程:異步,進(jìn)程可能和內(nèi)核競(jìng)爭(zhēng)CPU
這些組合就是常見的名詞,同步阻塞,同步非阻塞,異步非阻塞。其實(shí)真實(shí)的軟件世界里,阻塞式IO基本都是同步的,異步也都是非阻塞。
同步非阻塞就是十分常見的多路復(fù)用結(jié)合非阻塞IO實(shí)現(xiàn)的方案,也稱之為事件驅(qū)動(dòng)。同步有利于邏輯的書寫,非阻塞有利于調(diào)用率實(shí)現(xiàn)并發(fā)。因此現(xiàn)實(shí)中更多的IO模型是多路復(fù)用IO,并且在發(fā)展過程中,select,poll和epoll是逐步進(jìn)化鏈。epoll實(shí)現(xiàn)了內(nèi)核級(jí)數(shù)據(jù)結(jié)構(gòu)優(yōu)化,在實(shí)際性能上又了很大的提升。這些都會(huì)在后面的文檔介紹。
互聯(lián)網(wǎng)應(yīng)用出現(xiàn)實(shí)現(xiàn)了很多殺手級(jí)的產(chǎn)品和巨型公司。這些產(chǎn)品因?yàn)槭褂玫娜硕喽鴰砹撕芏嗉夹g(shù)革新,十年前C10K還是無需考慮的問題,如今很多應(yīng)用都面臨C10K,C100K,甚至是C1000K的挑戰(zhàn)。并發(fā)量越來越來,服務(wù)IO的并發(fā)模型也有多種實(shí)現(xiàn),對(duì)接下將討論的并發(fā)模型。本文對(duì)基本IO的介紹,有利于了解當(dāng)前不同高性能服務(wù)器的技術(shù)選型和服務(wù)原理。
后記:
網(wǎng)絡(luò)上很多文字解釋同步異步,阻塞非阻塞的概念,也有很多生動(dòng)的類比。曾經(jīng)我也試圖使用生活中的例子類類比網(wǎng)絡(luò)IO,雖然對(duì)于初次接入者有一定的友好性,現(xiàn)在看來這些類比其實(shí)是本末倒置,南轅北轍。現(xiàn)實(shí)中很少有完全匹配的case,因此為了類比反而會(huì)對(duì)其基本原理含糊其辭。最好的認(rèn)知方式就是代碼。
很多人將多路復(fù)用I/O歸結(jié)為異步IO,與《Unix網(wǎng)絡(luò)編程》編程里的定義是沖突。后者里多路復(fù)用IO是同步IO,因?yàn)檎嬲腎O還是阻塞或者非阻塞IO。只有aio_read的系統(tǒng)調(diào)用才是異步IO。很多使用epoll實(shí)現(xiàn)的服務(wù)軟件也聲稱是高性能異步框架,這里的“異步”更多是表示服務(wù)器并發(fā)處理請(qǐng)求的能力,而不是IO基本的“異步”。并發(fā)的請(qǐng)求交替處理,從整個(gè)服務(wù)器角度來看是一種異步行為。另外一些人容易誤解,一個(gè)原因大概就是想當(dāng)然“異步”一定要比“同步”性能高,其實(shí)也是不對(duì)的。真正的異步io未必就比同步的epoll好,而同步epoll在并發(fā)量很小的情況下,未必就比poll,select甚至是同步阻塞IO好。
??轉(zhuǎn)載。禁止轉(zhuǎn)載的原因不是所謂的版權(quán)。從我個(gè)人的經(jīng)驗(yàn)來看,blog的筆記是記錄當(dāng)時(shí)總結(jié)。隨著認(rèn)知的變換,可能之前的筆記會(huì)有錯(cuò)誤。我發(fā)現(xiàn)了一般都會(huì)返回去修正,甚至刪掉之前的內(nèi)容。如果把錯(cuò)誤一直留著,很可能會(huì)誤人子弟。而筆記一旦轉(zhuǎn)載出去,即使我修改原文了,轉(zhuǎn)載之后的也不會(huì)被修正,錯(cuò)誤還是會(huì)被保留。因此我希望不要轉(zhuǎn)載任何blog筆記。