天天看點

prim_inet:async_accept

無意中發現erlang伺服器挺多使用prim_inet:async_accept,而不是gen_tcp:accept。查詢了下文檔和源碼,這兩個接口的主要差別還是在于同步和異步。async_accept是異步的,而accept是同步的,會一直阻塞到有連接配接到來才會傳回。這裡就涉及了很多有意思的概念,tcp accept的過程、同步、異步、阻塞和非阻塞,這篇文章的目的就是簡要的記錄并理清這些概念。

gen_tcp:accept

Accepts an incoming connection request on a listen socket. Socket must be a socket returned from listen/2. Timeout specifies a timeout value in ms, defaults to infinity.

gen_tcp子產品提供的accept接口是一個阻塞的方法,我們需要指定timeout時間或者預設timeout時間為infinity。

prim_inet:async_accept

prim_inet:async_accept是一個非阻塞的異步accept接口。雖然文檔上并沒有說明該接口,隻是在内部調用中使用,但是在rabbitmq等很多伺服器的accept實作中都用到了這個異步accept的接口。

accept與async_accept的實作

檢視prim_inet子產品關于tcp accept的實作,可以發現,其實gen_tcp:accept的實作是基于async_accept的,gen_tcp:accept會一直阻塞在receive中等待消息。而直接調用async_accept的話,程序會在accept socket後收到消息{inet_async,…}。

{inet_async, L, Ref, {ok, S}}, S就是socket了。

%% For TCP sockets only.
%%
accept(L)            -> accept0(L, -).

accept(L, infinity)  -> accept0(L, -);
accept(L, Time)      -> accept0(L, Time).

accept0(L, Time) when is_port(L), is_integer(Time) ->
    case async_accept(L, Time) of
    {ok, Ref} ->
        receive 
        {inet_async, L, Ref, {ok,S}} ->
            accept_opts(L, S);
        {inet_async, L, Ref, Error} ->
            Error
        end;
    Error -> Error
    end.
           

回顧下unp上關于unix上異步accept的說法

核心中維護的2個隊列

listen的時候,核心會維護2個隊列,一個隊列存放未完成的連接配接,一個隊列存放已完成的連接配接。這裡的未完成的連接配接指的是用戶端第一次握手包被伺服器收到的連接配接,已完成的連接配接指的是用戶端第三次握手包被伺服器收到的連接配接。

阻塞accept 和 非阻塞accept

accept的時候,會從已完成的隊列擷取連接配接。如果這是一個阻塞的accept,則在已完成隊列是空的情況下會一直等待,直到有連接配接完成,傳回該連接配接的socket。如果這是一個非阻塞的accept,則在空的情況下,會馬上傳回ewouldblock的錯誤。

說了這些,好像跟上面的有關系,又沒什麼關系。其實這裡還需說明的是 異步、同步、阻塞和非阻塞的關系,鑒于很多書都探讨過,我也不好說什麼了。我更傾向于一種說法,在accept的這個情況中,我們使用非阻塞的accept才可以實作異步。為什麼呢?

阻塞accept + select =?= 異步accept

一般來說異步accept的實作,是使用select\poll或進階的epoll來監測listensokcetfd,一旦它有準備好的連接配接我們就會收到通知,然後調用accept進行接收。這樣看來,就算這個accept的接口是阻塞也沒什麼影響,listensocketfd傳回可讀事件,然後我們調用accept去接收。這确實是個正常的流程,可以這個流程是有風險的,會有一種坑爹的情況導緻我們調用accept的時候被阻塞了。

那就是當listensocket傳回可讀事件的時候,我們剛好沒法馬上就去調用accept,而在這段時間裡(連接配接完成,尚未accept的時間),用戶端這邊有剛好發來rst終止連接配接,伺服器這邊就會關閉連接配接。核心中已完成的連接配接隊列就會把這個資料清空了。那麼我們的問題就來了,這時候再去accept就會被阻塞了,知道下一個連接配接到來。顯然這個問題的存在,是沒法保證我們的 select + blocking accept的實作方案可以滿足異步accept的要求的。

是以才會有非阻塞accept,進而真正實作異步accept。

(注:unp一書有關于同步accept和異步accept的詳細探讨)

unix上的非阻塞accept 與 erlang實作的async_accept

erlang的gen_tcp:accept的實作是基于prim_inet:async_accept的,而prim_inet:async_accept是異步的方式。

是以,erlang底層關于prim_inet:async_accept的實作應該就是上面說的 nonblocking accept + select/poll/epoll 的方式。

為什麼要使用async_accept?

關于這個問題,大多數的看法是想當然地認為,因為async_accept是異步的,而accept是同步的,異步當然比同步的進階,是以我們使用async_accept。

上面的這種看法,好像蠻對的,但沒有具體說原因。網上有一種解釋,大概是這樣的,當我們的listensocket在同一時刻收到了大量的請求連接配接時,async_accept可以同時處理這些連接配接,而gen_tcp:accept隻能一個個地處理,然後呢,處理連接配接的時間需要一次三次握手的時間。那麼後面的連接配接就受不了了,不想等待了。

這種說法,我覺得是錯誤的,這裡的概念是認為accept觸發了三次握手。其實accept跟三次握手真沒什麼聯系,上文unp講到accept隻是在已完成連接配接隊列裡面擷取連接配接,是以就算不accept,連接配接照樣會建立。隻不過這個隊列的數量和listen時候的參數backlog有關。

那麼為什麼要使用async_accept,相比于普通的accept,優勢在哪裡?其實異步accept和同步accept的差別就在于調用的時候會不會阻塞程序,在非阻塞的情況下程序可以在等待socket的這段時間去做其他事情,而調用阻塞接口的程序隻能一直阻塞着等待socket。同一時間有大量連接配接存在,也就說已完成連接配接隊列裡有多個連接配接的時候,不管是async_accept還是同步accept,都是一次接收一個連接配接的。

假設有兩種實作:

實作1是,一個accept程序調用gen_tcp:accept,等待擷取連接配接,一旦連接配接成功該socket就交給業務程序處理,然後accept程序繼續阻塞,等待下一次擷取連接配接。

實作2是,一個accept程序調用prim_inet:async_accept,然後等待處理連接配接消息,該消息的處理方式:也是把socket交給業務程序處理,然後在沒有收到連接配接消息的時候,他也可以處理其他消息。

實作1和實作2的差別,就在于accept程序有沒有空閑時間出來處理其他的業務,實作2的accept除了處理{inet_async…}消息,也可以比對處理其他消息。

但是當同一時刻大量連接配接到來時,實作2相比實作1是否能夠更能提高accept的效率呢,我認為是不可以的。因為實作1和實作2一樣得從核心的隊列中擷取連接配接。但是這一點隻是個猜想,還需驗證。

多程序同時accept

上面說到的問題,當同一時刻有大量連接配接到來,怎麼更高效地accept這個連接配接呢?畢竟上文隻是說了單個程序使用async_accept并不能提高這種情況的處理效率。其實這個情況我們可以建立多個程序同時accept socket。這個方案也是很常見的,ranch(erlang的網絡accept庫)就是使用了這一方案。ranch的做法大概是由ranch_acceptor_sup開始gen_tcp:listen,并把得到的listensokcet傳遞給多個子程序ranch_acceptor,然後每個ranch_acceptor都開始阻塞在gen_tcp:accept(listensocket, Timeout)上,然後在同時大量連接配接到來的時候,這些程序會有序地一個個accept到socket。

說到多程序accept不可避免的必須提到“驚群問題”,驚群問題已經在linux核心中解決了。那麼我還是不說了哈哈。其實我們一定很好奇,那麼多個ranch_acceptor都阻塞在accept中,但隻有一個連接配接到來的時候,誰會accept成功傳回,其他程序又會怎樣。解決這個問題必須用到鎖了,但是很開心的是核心已經解決了這個複雜的資源等待與使用問題,我們隻管這麼用就行,也就是核心會在效率較高地情況下選擇合适程序。

關于這個選擇哪個程序,為什麼選擇他,這裡真不是很清楚,這一部分應該是核心封裝了,要麼就是gen_tcp:accept封裝了。用ranch做了一個測試,當連接配接一個個到來的時候,發現ranch_acceptor程序是很有順序地一個個被調用的然後傳回socket,可見内部的排程算法還是很合理的,而且也預料到會有多程序調用這種情況的出現。