併發處理中的問題以及解決這些問題的併發模型

cadem發表於2017-04-26

單機併發是叢集併發的基礎。本文主要將單機併發問題,和解決這些單機併發問題的解決模型。本文只討論單機併發,叢集併發將在我的後續其他文章中討論,所以本文將單機併發簡化稱為併發,省去單機二字。

1. 併發問題

什麼併發問題,舉個例子,一個伺服器,有大量的連結上來,每個連結同時發請求。另外一種情況,只有一個連結到伺服器,但這個連結短時間內傳送大量的請求。有些人只是把第一種場景稱之為併發,這種場景多是直接面向使用者的,比如web伺服器,但是第二種場景也是併發,比如SOA架構中的服務。

這兩種的併發是有區別,而且有很多種方式來實現解決,這裡可以參看我的關於IO模型的討論。但是這裡我們採用一種統一的方式來處理,即將每個連結上的請求放入一個佇列,如何高效的將所有請求放入佇列可以參考IO模型的討論。這是一種非常常見的處理方式,大多數伺服器和服務框架都採用這種方式。

從佇列中取出請求進行計算處理是併發的另外一部分,本文討論的就是這一部分。所以併發就是同時處理多個請求,如何提高同時處理請求的數量就是併發問題。

提高併發首先要知道我們要解決哪些問題,併發問題隱含以下3個問題:

  • 1.多路執行問題。
  • 2.多路間的通訊問題。
  • 3.呼叫問題(包括耗時阻塞呼叫問題,並且呼叫存在多個,之間存在複雜的串並行關係)。

1.1 什麼是多路執行問題

要提高併發能力最基本的方式就是同時多路執行。多路執行是邏輯上的概念,往簡單裡說就是多程式執行或者多執行緒執行等。這是往簡單裡說,實際上多路執行是一個比較複雜的問題,後續會有解釋。

1.2 什麼是多路間的通訊問題

有了多路執行,但是每一路執行都不是孤立,比如都需要一些共同的資料,或者一路的執行需要另一路執行提供資料。那麼這就需要多路間的通訊。比如,如果是多程式方式的多路執行,就是程式間通訊問題。

1.3 什麼是呼叫問題

併發問題並不是一個單純獨立的問題,實際的併發問題往往是一個很複雜的問題。比如網路服務,提高一個網路服務的併發能力,這個網路服務接收到一個請求後還要請求其他網路服務才能完成這個請求,請求其他網路服務往往是一個耗時的阻塞呼叫。要提高併發能力,就需要解決耗時阻塞呼叫問題。並且在實際問題中,這些呼叫有可能存在多個,並且多個呼叫間可能還存在複雜的串並行關係。

下面會討論如何解決這些問題,並且針對這些問題總結出問題解決模型。三個問題各自有各自的解決模型。

2. 多路執行問題的解決模型

2.1 兩種模型

解決多路執行問題的模型有2個:

  • 多執行緒/程式(即物理執行緒/程式)模型
  • 使用者態執行緒/輕量級執行緒(程式)模型

實現多路執行,自然想到的就是使用多程式或者多執行緒來實現,這也是最常見的一種解決模型。這種模型中的執行緒和程式是物理執行緒和物理程式,”物理”是指作業系統的提供的。一個物理執行緒/程式就是一路執行。各種語言都會實現這種模型。

我們也可以使用一個物理執行緒實現多路執行,即物理執行緒在不同的執行間進行切換。一般來講,這種同一個物理執行緒裡的不同執行會被稱為輕量級執行緒,即在一個物理執行緒中模擬出多個使用者態執行緒或者叫輕量級執行緒。想對於物理執行緒,這種輕量級執行緒,不需要作業系統做切換,即切換時不需要從作業系統的使用者態轉入的核心態,所以這種輕量級執行緒,也叫做使用者態執行緒。在erlang中,也有輕量級的概念,但是erlang是輕量的程式,但本質上和輕量級執行緒是一樣的。這三種叫法:使用者態執行緒,輕量級執行緒,輕量級程式,本質上來講是一樣的,所以本文後續只用使用者態執行緒這種叫法。

2.2 使用者態執行緒模型的具體說明

2.2.1 使用者態執行緒的實現方式

使用者態執行緒是通過切分物理執行緒的方式實現的。
使用者態執行緒的本質就是切分物理執行緒。

不同的語言實現使用者態執行緒的方式不同:

  • C/C++中的使用者態執行緒

    C/C++中是通過協程技術實現的使用者態執行緒,詳細的描述可以參看我關於c++協程的文章。
    
  • erlang中的使用者態執行緒

    erlang中有輕量級程式的概念,是基於虛擬機器實現的。
    
  • go中的使用者態執行緒

    go語言中的使用者態執行緒叫做goroutine,是基於協程技術實現的
    
  • scala中的使用者態執行緒

    scala中的使用者態執行緒叫做actor
    
  • Java中的使用者態執行緒

    Java可以採用第三方庫實現使用者態執行緒,詳細的描述可以參看我關於Java併發的文章。
    

2.2.1.1 使用者態執行緒與協程的關係

可以看到很多語言中的使用者態執行緒都採用協程技術,但是協程並不等價與使用者態執行緒,使用者態執行緒也是基於協程實現的,剛剛我們說了使用者態執行緒的本質是執行緒切分。

比如scala中的actor,因為scala是也基於java的底層庫,Java中是沒有內建支援協程的,所以actor的實現就不是基於協程的。scala中acotor的實現類似於這樣的實現:

通常都是建立任務佇列,1個執行緒或多個執行緒,或者執行緒池,從佇列中取出並且執行這些任務,每個任務相當與一個actor。

在這樣的實現中任務是順序執行的,也就是說任務不能在中途切換到另一個任務。協程技術可以實現在任務的中途切換到另一個任務。也就是說協程的本質可以說是一種使用者態執行緒的切換機制。詳細的關於協程的描述可以參看我關於協程的文章。

2.2.2 使用者態執行緒排程策略

使用者態執行緒有三種排程策略:

  • 1.順序的排程策略(在只實現了任務佇列+執行緒池的實現方式中,任務是順序執行的,比如,scala的actor)
  • 2.協作式執行緒排程,基於協程的使用者執行緒切換
  • 3.輪詢的排程策略(erlang的輪轉排程策略,go 1.2後具有簡單搶佔機制的排程策略)

2.2.3 使用者態執行緒抽象模型

無論用什麼方法實現使用者態執行緒,最終都需要通過物理執行緒實現。所以使用者態執行緒一定和物理執行緒有一定的對應關係,也即使用者態執行緒抽象模型,抽象模型包括三種:
1:1
N:1
n:m

1:1
即一個物理執行緒抽象成一個使用者態執行緒
N:1
一個物理執行緒模擬N個使用者態執行緒
N:M
m個物理執行緒模擬n個使用者態執行緒,比如go中的pmg的概念,更復雜的3元抽象

3.阻塞呼叫問題的解決模型

解決阻塞呼叫問題,通常有三種方式,也即有三種解決模型:

  • 1.多執行緒模型(保持阻塞用多執行緒規避)
  • 2.回撥模型
  • 3.協程切換模型

第一種模型的解決方式是,遇到阻塞呼叫時,保持阻塞呼叫,也即不做任何處理,而是採用啟動多個執行緒的方式提高併發能力。
第二種回撥模型,採用非同步技術,將阻塞呼叫變成非同步呼叫,當呼叫完成時通過回撥的方式通知呼叫者。這樣一個執行緒就可以同時處理多個併發請求。這種模型是一種最高效的模型。避免的過多的物理執行緒頻繁切換帶來的不必要的開銷。
第三種協程切換模型,也採用非同步技術,將阻塞呼叫變成非同步呼叫,所以從效率上來講,這種模型的效率並部高於第二種模型,為什麼會出現這種模型,是因為第二種回撥模型程式設計複雜。協程切換模型簡化了這種複雜性。

通常這三種模型被總結成三種併發模型,網上對併發模型的分類: 多執行緒模型,非同步回撥模型,輕量級執行緒/協程模型。

我們繼續文章最開始的例子,請求被放入一個佇列中,我們分以下接個討論,看看如何併發處理這些佇列。在處理實際請求時,可能需要訪問資料庫,呼叫其他服務等等,這些操作都可以簡化成,向網路傳送一個請求,再從網路接收一個回覆。

3.1 多執行緒模型示例

虛擬碼如下:

main_processing()
{
    loop
    {
        request = queue.get();
        create_thread (handle_request, request);
    }
}

handle_request(request)
{
    sendto(server1);
    receivefrom(server1);
    
    sendto(server2);
    receivefrom(server2);
    
    sendto(server3);
    receivefrom(server3);
}

在請求處理函式中,receivefrom()是阻塞呼叫。

3.2 回撥模型示例

這種模型中引入了非同步技術。

非常簡略的示意虛擬碼:

request_process_loop()
{
    loop
    {
        if (!queue.empty())
        {
            request = queue.get();
            handle_request(request);
        }
    }
}

handle_request(request)
{
    sendto (server1, request, handle_request_callback1);
}

handle_request_callback1(response)
{
    sendto (server2, request, handle_request_callback2);
}

handle_request_callback2(response)
{
    sendto (server3, request, handle_request_callback3);
}

handle_request_callback3(response)
{
    print (response.message);
}

在這種模型中send是非同步的,並且傳入了一個回撥函式,不需要顯示呼叫recieve,系統在收到response時,會呼叫回撥函式。這裡省略了採用IO複用機制,呼叫callback函式的細節。具體的技術細節參看IO模型。
這種模型種不會引入過多執行緒,一般一個執行緒就可以處理併發請求,處理效率是最高的。但也可以看到同樣的業務邏輯不得不分散到3個回撥函式中,程式設計複雜。

3.3 協程切換模型

這種模型也引入了非同步技術。同時採用協程技術避免了callback。

非常簡略的示意虛擬碼:

main_processing()
{
    loop
    {
        request = queue.get();
        create_coroutine (handle_request, request);
    }
}

handle_request(request)
{
    sendto(server1);
    reponse = switchout();
    
    sendto(server2);
    reponse = switchout();
    
    sendto(server3);
    reponse = switchout();
}

recieve_processing()
{
    loop
    {
        recievefrom( any );
        continue_coroutine();
    }
}

這裡省略的很多實現的細節。但是通過協程技術,即保證了處理請求的併發性,同時也降低了程式設計的複雜度,基本和多執行緒模型的難度是一樣的。實際上,在具體的語言和框架中,通過封裝完全可以達到使用上與多執行緒模型完全一致,採用同一種”併發程式設計模型”,雖然底層採用了完全不同的”併發模型”。在本文的第6節,會描述併發程式設計模型。

4. 多路間通訊問題的解決模型

多路間通訊問題的解決模型有2種:

  • 1.執行緒同步機制模型

    
    這種模型是基於共享記憶體和執行緒間的同步機制(即各種鎖)
  • 2.message傳遞模型

    
    這種模型採用message傳遞的方式來進行多路間的通訊。這種模型有兩種具體的實現方式(也即有兩種程式設計模型,在第6節會詳細描述):actor模型,SCP模型。
    

5. 併發技術

上面提到了併發處理模型中使用的三種主要技術:
多核/多執行緒(程式)技術
非同步技術
協程技術

這3種技術在鬢髮處理模型中分別起到了不同的作用:
多核/多執行緒(程式)技術:提高同時處理的數量
非同步技術:降低不必要的消耗
協程技術:降低程式設計難度

6. 併發程式設計模型

以上討論的併發處理的模型,這些模型在不同的語言和框架中被設計成不同的形式,有不同的使用介面和方式。這些不同的使用方式就是併發程式設計模型。

6.1 多執行緒+執行緒同步機制模型

多執行緒+執行緒同步機制的程式設計模型是邏輯上最自然的的程式設計模型,也是最普通的程式設計模型,最容易理解。

在這種模型中,要併發處理任務就建立執行緒,執行緒間的通訊採用共享記憶體和執行緒同步機制。如在第三節中的討論,雖然使用方式和介面都一樣,但是底層的實現可以採用完全不同的併發模型,所以這種程式設計模型中,的執行緒可以是物理執行緒,也可以是使用者態執行緒。

在這種模型可以從這種多執行緒模型演變成執行緒池模型,但本質上是一樣的。

在第7節,Java1.0和c++ threadstate庫都屬於這種程式設計模型。

6.2 執行緒池+任務+Future/Promise的程式設計模型

這種程式設計模型主要聚焦線上程同步機制。執行緒同步機制即各種鎖,必須正確使用才能避免死鎖等各種問題。這種程式設計模式就是提出了一種執行緒同步方式,也即總結一種比較常見的使用場景,提出了一個模式(pattern),簡化執行緒同步。

一般這種的模式的介面定義如下:

Promise<T>
{
    Future Get_future();
    Set_value(T);
}

Future<T>
{
    T Get_value();
    Wait();
}

這種模式還有進一步的演化,就是Callback chain by then,新增新介面如下:

Future<T>
{
    Then(AsyncFunc)    ;
}

這裡我們就不舉例子,在第7節,我們會看到Java和scala是如何實現這種程式設計模型的。

6.3 基於事件+狀態機的程式設計模型

我們在3.2節中看到,基於回撥的併發模型,在程式設計模型層面,我們可以把callback抽象成事件,在複雜的業務邏輯中,很難控制callback間的跳轉,這時我們可以引入有限狀態機來進行管理。

6.4 Actor模型

在Actor模型中,actor是一個獨立的單元,是一個使用者態的執行緒,actor與actor之間採用message進行多路間的通訊。message傳遞的機制是每個actor內部都有一個mailbox,actor可以向其他actor的mailbox投遞訊息,每個actor只能從自己的mailbox中取訊息進行處理。

Actor模型的典型的實現是scala和erlang。其他語言也有對actor模型的實現,在第7節會詳細描述各語言的實現。

6.5 CSP模型

CSP(Communicating Sequential Processes)也是一種基於message傳遞的併發程式設計模型。與actor模型的不同之處在於,在CSP中有2中角色,worker和channel。worker是使用者態執行緒,channel使用者message傳遞。

CSP模型是golang採用的程式設計模型。

7. 實際的例子

以上都是一些理論上的討論,下一些比較典型的例子說明各種語言和框架對併發的支援。

7.1 Java

物理執行緒,執行緒同步機制

引入了ExecutorService, Callable, TaskFuture。

但是沒有解決阻塞呼叫的問題

7.2 Node.js

7.3 Scala/Akka

基於java concurrent實現的logic執行緒,即actor,基於訊息傳遞的mailbox

7.4 c++ StateThread 協程庫

基於非同步和協程技術,實現了多執行緒+執行緒同步機制模型。

這個種提供了執行緒管理、執行緒同步、網路訪問等方法介面。

比如:

st_thread_create建立一個新的使用者執行緒

st_read從網路中讀取,這個操作會阻塞使用者執行緒,但通過協程技術,物理執行緒切換到另外一個可執行的使用者態執行緒繼續執行

7.5 Go

協程,對阻塞系統呼叫的hack處理的green化方式,基於channel的訊息傳遞模型


相關文章