1. 程式人生 > >同步、非同步、阻塞、非阻塞,以及IO模型的理解

同步、非同步、阻塞、非阻塞,以及IO模型的理解

同步和非同步

同步 就是你知道你什麼時候在做什麼,做完一件事情再做下一件事情,因此主動權在自己手裡。比如通過等待或輪詢,你在某個時間點總是知道結果是怎樣的(有資料還是沒資料等)。
非同步 就是你不知道什麼時候會發生什麼。比如你註冊了多個回撥函式,你不知道什麼時候會被呼叫以及被呼叫的是哪一個回撥函式。

阻塞和非阻塞

阻塞:一個呼叫過程必須完成才返回。對於IO操作,如果IO沒有準備好,讀取或者寫入等函式將一直等待。
非阻塞:一個呼叫過程會立即返回,無論結果怎樣。對於IO操作,讀取或者寫入函式總會立即返回一個狀態,要麼讀取成功,要麼讀取失敗(沒有資料或被訊號中斷等)。
看微博上有人(屈春河的微博)說還有部分

阻塞:整個呼叫過程分為C1,C2,…,Cn步,呼叫者完成C1,…,Cj步後就返回,而不是等待完成整個呼叫過程。

組合方式

通常有三種組合方式:
同步阻塞:所有動作依次順序執行。單執行緒可完成。
同步非阻塞:呼叫者一般通過輪詢方式檢測處理是否完成。單執行緒可完成。
對於IO操作來講,我們熟悉的select/epoll,即IO多路複用,可以認為屬於這種工作方式。不過有人說select/epoll是非同步阻塞的方式,這是為啥呢?明明select/epoll過程在使用者態看來類似於輪詢,而讀寫過程可以非阻塞的。實際上select/epoll過程是阻塞的,但它的好處是可以同時監聽多個fd且可以設定超時,並且利用select/epoll的阻塞換取了讀寫的非阻塞。
非同步非阻塞

:Callback模式,註冊回撥,等待其他執行緒利用回撥執行後續處理。Linux kernel裡面有個aio就是非同步非阻塞IO,但好像很多坑。

IO操作中的同步和非同步

需要再重複一下幾種IO模型:
1.阻塞I/O (blocking I/O):recv和recvfrom是阻塞的,即沒有資料到來之前本執行緒不能做其他事情。
2.非阻塞I/O (nonblocking I/O):recv和recvfrom沒有資料時也立即返回,但要一直進行recv/recvfrom並輪詢返回值,浪費CPU。可以用fcntl來設定非阻塞:
int fcntl(int fd, int cmd, long arg);
例如:
sockfd = socket(AF_INET, SOCK_STREAM, 0);
fcntl(sockfd, F_SETFL, O_NONBLOCK);
3.I/O多路複用

(I/O multiplexing):select/poll/epoll,沒資料時會阻塞,有資料的時候返回就可以進行recv了,它和阻塞I/O的區別是可以同時監聽多個套接字描述符上的資料,有一個有資料就返回。而阻塞I/O是阻塞在recv那個地方,而且recv的時候已經指定某一個套接字了。
4.訊號驅動I/O (signal driven I/O (SIGIO)):通過註冊SIGIO的訊號處理函式,來監聽和接收資料(我沒用過)。
5.非同步I/O (asynchronous I/O):利用aio機制,設定存放資料的快取、回撥函式以及回撥的方式(執行緒或訊號等),並呼叫aio_read()讀資料並立即返回。核心中準備好資料並拷貝完成後,通知使用者態去讀。

可見,這裡並不是按照同步非同步和阻塞非阻塞的組合給出的,我們也不必去糾結。但是非同步IO模型和其他4種模型的一個顯著區別在於:前4種模型最終呼叫recv去讀,讀的過程包括資料拷貝並返回狀態,交給使用者態處理;而非同步IO在核心中默默地拷貝資料,記錄狀態,使用者態省去了讀操作,而是直接處理資料,這也是aio為什麼需要預先分配讀快取的原因。
如果非要套用同步和非同步的概念,那我認為是按照recv(將資料拷貝到使用者態)的時機來區分的:對於非同步IO,使用者態並不知道什麼時候recv,因為kernel已經幫忙做好了,而其他4中模型都是使用者程式自己主動去recv的。

類比匯流排協議?

這讓我想到了同步匯流排和非同步匯流排,他倆一個明顯區別是:
同步匯流排的通訊雙方總是通過一個統一的時鐘來進行同步,比如串列埠,你首先要設定一個波特率,並且通訊雙方的這個值必須一致。也就是說兩端都知道什麼時候傳送資料,什麼時候能夠期望收到對端的資料。某一方的時鐘頻率相差很大都會導致資料傳輸混亂。
而非同步匯流排的通訊協議中會要求某一端提供時鐘(通常是master),例如I2C和SMI,並且並不要求時鐘頻率恆定。Master想傳送資料了,才開始產生時鐘,時鐘間隔可以不固定。那麼slave(可能有多個slaves,通過片選來確定資料要傳送到哪個slave)只能在收到時鐘以及一段opcode後才知道有資料來以及要做什麼操作。
這實際上和軟體上的同步、非同步的概念類似。

再說IO模型

再說說各種IO模型的適用場景。

由於現實中場景複雜,因此需要根據不同場景選擇合適的IO模型。IO過程主要是硬體上操作時間比較長,資料傳輸通常又有DMA,所以IO過程中CPU消耗並不大。所以執行緒做IO時CPU可以做別的事情,因而不適宜讓IO將CPU阻塞,我們選擇IO模型的目標就是在保證IO正常的前提下儘量提高CPU利用率。

上面的幾種IO模型中:阻塞、非阻塞、非同步IO(aio),通常用於塊裝置讀寫,IO傳輸效率和CPU利用率是主要要評估的點。而多路複用(包括事件通知機制libevent等)、signal IO則常用於字元裝置或網路socket。因為它們更多的是監聽事件,考慮是否能及時收到並正確處理事件。

例如網路發包時,如果組包、發包的過程放在一個執行緒裡序列的做,那麼如果發包buffer滿了就只能等待buffer可用才能把新的包放下去,這樣就沒辦法完全利用CPU。可以用一個執行緒組包以及做其他事情,另一個執行緒發包,或者發包用非阻塞方式,看到buffer滿就先返回做一些其他事情,再嘗試重發。

阻塞IO:核心程式在等待一個IO時,如果讀不到東西,就阻塞了(核心會通過set_current_state(TASK_INTERRUPTIBLE)讓執行緒設定為可中斷,表示你要等待某事件或資源而睡眠,然後呼叫schedule放棄CPU),直到IO準備好了讀到東西返回,或者被訊號中斷返回。如果是被訊號中斷返回的,會伴隨錯誤碼EINTR

阻塞IO對於在一個執行緒中存在併發IO操作或者需要監聽多個裝置時就不適合。當然你可以通過多執行緒或多程序來分別處理每種IO操作或每個socket,程式設計也簡單,但建立執行緒本身有資源開銷,並且存在任務切換和任務通訊的開銷,效率不高。

非阻塞IO:純碎的非阻塞一般不用,因為每次去無腦地輪詢看起來很傻。所以都是和select/epoll結合用的,等到資料ready了再用非阻塞去讀。
用非阻塞時(在讀時設定MSG_DONTWAIT或者用fcntl設定F_SETFL為O_NONBLOCK),如果沒有資料了你也要任性去讀的話,會直接返回EAGAIN,即Resource temporarily unavailable。

多路複用:就是一個執行緒中處理多個IO,例如select和epoll,它們可同時監聽多個IO事件,有一個事件發生就返回,處理完這個事件後繼續監聽。實際上監聽的過程是阻塞的,但比阻塞IO的好處是可以在一個執行緒裡同時監聽多個IO事件。

select把所有fd都加到一個集合(set)裡面,然後監控這個set,有一個fd發生事件就返回,這個監控的過程在核心中是通過遍歷set,並且select返回後,你在使用者態也要遍歷所有fd列表來查詢是listenfd還是接入的fd產生的事件以及哪個接入fd的事件,因此整個過程要兩次遍歷。另外,每次select都要重新設定整個set到核心裡,前期準備工作也耗時。select在Linux裡的fd數量限制由__FD_SETSIZE定義,一般是1024。

epoll解決了select處理fd集合的上述兩個問題,它把設定set和等待事件的api分離(epoll_ctl和epoll_wait),不用每次等待前都設定set;另外epoll_wait的第二個引數會儲存哪些fd產生事件了,因此不用掃描整個set列表。這樣就可以更高效地監聽更多的事件。
epoll_wait等到事件後不要做太多事情,防止又有新的fd準備好了。所以epoll也會結合線程池等設計,將事件的處理放到任務佇列裡去,然後儘快重新epoll_wait。

但是和select相比,為了解決上述問題epoll引入了監控fd的紅黑樹,和ready事件的list,這些是額外的開銷。

這裡也說一下多路複用時對訊號的處理:
select從核心返回到使用者態之前會檢查是否有未處理的signal,如果有signal pending,也就是被訊號中斷了,就會返回-ERESTARTSYS到標準庫(我實際看到的是ERESTARTNOHAND)。有人給你kill了訊號,你當然要處理這個訊號啊,因此會執行訊號處理函式,然後會確定該系統呼叫是返回到使用者程式還是重新執行這個被打斷的系統呼叫。
這是根據這個訊號的處理是否設定了SA_RESTART標記來決定的,如果設定了SA_RESTART,則系統呼叫被訊號打斷,CPU轉而執行訊號處理函式完成後,系統呼叫會重新執行;而如果不設定SA_RESTART標記,則執行完訊號處理函式後就返回到使用者程式了。至於訊號是否預設設定了SA_RESTART,可以通過strace去跟一下,例如對於訊號SIGINT預設不設定這個標記。

例如阻塞的方式呼叫read()/recv()的時候,假如sigaction的時候對訊號設定了SA_RESTART標記,那麼如果read過程中被該訊號打斷,執行完訊號處理函式後,read會繼續進入核心執行繼續阻塞而不是返回。而如果訊號沒設定SA_RESTART的話,在呼叫完訊號處理函式之後,read立即返回,read返回-1並設定出錯碼為EINTR

在select和read的時候都需要注意檢查這個錯誤碼看是不是被訊號中斷而返回的。

自動忽略SA_RESTART的系統呼叫
然後對於一些對超時時間敏感的系統呼叫,如select/usleep,會忽略訊號的SA_RESTART標記,無論是否設定該標記,總是返回到使用者程式。例如sleep,被訊號中斷後就返回,不管你是否給這個訊號設定了SA_RESTART標記。因為你sleep(5)想睡5s,結果睡2s後被中斷,不可能自動重發再睡5s。

另外說一下,每次select完之後,引數tv會被賦值為尚未等待的時長,例如設定5s,但select等了2s就返回了,那tv中儲存3s。nanosleep()也是一樣,它可能在睡眠過程中被訊號打斷而返回,但你可以通過檢查nanosleep沒睡夠的時間讓睡眠更精確一些。

signal()函式的man page中說明了有哪些系統呼叫,即使你設定了自動重發(SA_RESTART)也不會自動重發。

The following interfaces are never restarted after being interrupted by
a signal handler, regardless of the use of SA_RESTART; they always fail
with the error EINTR when interrupted by a signal handler:

   * Socket interfaces, when a timeout has  been  set  on  the  socket
     using   setsockopt(2):   accept(2),   recv(2),  recvfrom(2),  and
     recvmsg(2), if a receive timeout (SO_RCVTIMEO) has been set; con‐
     nect(2),  send(2),  sendto(2),  and sendmsg(2), if a send timeout
     (SO_SNDTIMEO) has been set.

   * Interfaces used to wait  for  signals:  pause(2),  sigsuspend(2),
     sigtimedwait(2), and sigwaitinfo(2).

   * File    descriptor    multiplexing   interfaces:   epoll_wait(2),
     epoll_pwait(2), poll(2), ppoll(2), select(2), and pselect(2).

   * System V IPC interfaces: msgrcv(2), msgsnd(2), semop(2), and sem‐
     timedop(2).

   * Sleep    interfaces:    clock_nanosleep(2),   nanosleep(2),   and
     usleep(3).

   * read(2) from an inotify(7) file descriptor.

   * io_getevents(2).

The sleep(3) function is also never restarted if interrupted by a  han
dler,  but  gives  a success return: the number of seconds remaining to
sleep.

signal IO:現在基本沒人用了,因為用它的程式架構不好看,有點過度設計了。它就是設定一個特定的訊號SIGIO的處理函式。核心發現事件(具體什麼fd什麼事件核心裡面自己實現)後就kill一個SIGIO訊號給使用者程式,然後在訊號處理函式中做事情。

非同步IO:即aio。aio有兩個版本,glibc的aio,以及kernel裡的純碎的非同步aio機制。即發起一個IO後,我不需要原地等,後臺會在IO ready之後幫你讀到指定的資料區,當我在某一個點上需要同步等待它讀完的時候,就呼叫xxx_suspend。也就是說,資料的讀取是核心或glib幫你做的,並且你可以在任何你需要資料的時候再去等待IO。

glibc的aio,你呼叫aio_read會立即返回,glib開一個後臺執行緒幫你同步去讀,當你執行到某個點確實需要等待資料讀完時,你呼叫一個aio_suspend來等待就可以了(當然如果這時資料已經讀出來了,aio_suspend就立即返回了)。

kernel裡的aio機制,使用者態介面為io_setup/io_submit/io_getevents/io_destroy,這幾個API分別用來準備上下文、類似aio_read釋出IO請求的過程、類似aio_suspend的同步等待過程、銷燬上下文。使用時要加-laio。
kernel的aio通常跟O_DIRECT配合用,例如有些人想在使用者態自己做資料快取,而不用核心的page cache,就用O_DIRECT開啟硬碟,用aio讀資料。使用kernel的aio時,由於kernel裡面的aio還不是很完善,進化的比較慢,目前只用O_DIRECT開啟檔案才能用kernel的aio

glibc_aio和kernel_aio介面的用法見man page。

事件觸發: libevent利用epoll做了一層封裝,做成基於非同步事件通知的IO模型,它實際上就是讓你先event_add註冊事件處理函式,然後dispatch裡面不斷的去epoll_wait,有事件發生就呼叫對應的callback函式。我覺得就類似minigui裡面的按鈕事件的proc函式一樣,你點了按鈕就呼叫預先註冊好的按鈕事件處理函式。並且libevent是跨平臺的,防止epoll在別的系統上沒有。