簡(jiǎn)明網(wǎng)絡(luò)I/O與并發(fā) --- I/O

簡(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è)交互過程如圖:

server-client.jpg

左邊的圖使用了 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。大致流程如下圖:

response-io.jpg

上圖只表述了server處理響應(yīng)的過程:

  1. server的進(jìn)程發(fā)起Read系統(tǒng)調(diào)用,內(nèi)核隨即從硬件Disk讀取數(shù)據(jù)到內(nèi)核緩沖區(qū)(kernel buf)
  2. 內(nèi)核再把緩沖區(qū)的數(shù)據(jù)copy到應(yīng)用程序進(jìn)程的緩沖區(qū),應(yīng)用程序就可以對(duì)數(shù)據(jù)進(jìn)行修改。
  3. 應(yīng)用進(jìn)程將數(shù)據(jù)通過系統(tǒng)調(diào)用Send發(fā)送到socket緩沖區(qū),每個(gè)socket文件都在內(nèi)核維護(hù)了一個(gè)發(fā)送/接受緩沖區(qū)。
  4. 最后再把socket發(fā)送緩沖區(qū)的數(shù)據(jù)copy到NIC網(wǎng)卡中,通過協(xié)議棧發(fā)送到對(duì)端的網(wǎng)卡中。
  5. 對(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)。

basic-io.jpg

應(yīng)用的讀IO操作,即將網(wǎng)卡的數(shù)據(jù),copy到應(yīng)用的進(jìn)程buf,中途會(huì)經(jīng)過內(nèi)核的buf。

  1. 應(yīng)用進(jìn)行發(fā)起read系統(tǒng)調(diào)用。
  2. 內(nèi)核接受應(yīng)用的請(qǐng)求,如果內(nèi)核buf有數(shù)據(jù),則把數(shù)據(jù)copy到應(yīng)用buf中,調(diào)用結(jié)束。
  3. 如果內(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 中
  4. 內(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:

  1. 阻塞I/O(blocking)
  2. 非阻塞I/O(nonblocking)
  3. 多路復(fù)用I/O(multiplexing)
  4. 信號(hào)驅(qū)動(dòng)I/O(SIGIO)
  5. 異步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è)階段都阻塞,即線程掛起,不能做別的事情。

blocking.jpg

紅色的虛線表示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ù)做別的。

nonblocking.jpg

正如圖上所示,在真實(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ù)。

multiplexing.jpg

進(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í)候是配合非阻塞的方式使用。如下圖:

multiplexing-unblocking.jpg

一般多路復(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è)過程毫無疑問還是阻塞的。

signal.jpg

異步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模型如下:

async.jpg

圖中的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筆記。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
禁止轉(zhuǎn)載,如需轉(zhuǎn)載請(qǐng)通過簡(jiǎn)信或評(píng)論聯(lián)系作者。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,622評(píng)論 6 544
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,716評(píng)論 3 429
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,746評(píng)論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,991評(píng)論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 72,706評(píng)論 6 413
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 56,036評(píng)論 1 329
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,029評(píng)論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 43,203評(píng)論 0 290
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,725評(píng)論 1 336
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 41,451評(píng)論 3 361
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 43,677評(píng)論 1 374
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,161評(píng)論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,857評(píng)論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,266評(píng)論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,606評(píng)論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 52,407評(píng)論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 48,643評(píng)論 2 380