Elixir 簡明筆記(十九) --- 多進程

多進程

Elixir強大的并發(fā)來自其actor并發(fā)模型,簡而言之就是可以使用大量的進程來實現(xiàn)并發(fā)。elixir中的進程依托與erlang虛擬機的存在,這個進程與操作系統(tǒng)的進程不一樣,雖然他們可以像原生進程一樣在處理器中運行,但是他們比原生進程輕,在普通機器創(chuàng)建十萬個進程是輕而易舉的事情,甚至比普通語言創(chuàng)建線程還要輕便。下面就看看elixir的多進程是如何work。

使用erlang的timer模塊,模擬進程中耗時的操作。定義一個匿名函數(shù),運行函數(shù)之后,可以看到iex在兩秒之后才打印消息,連續(xù)調(diào)用5次,則一共耗時10秒:


iex(1)> run_query = fn query_def ->
...(1)>     :timer.sleep 2000
...(1)>     "#{query_def} result"
...(1)> end
#Function<6.54118792/1 in :erl_eval.expr/5>
iex(2)> run_query.("query 1")
"query 1 result"
iex(3)> 1..5 |> Enum.map(&(run_query.("query #{&1}")))
["query 1 result", "query 2 result", "query 3 result", "query 4 result",
 "query 5 result"]

創(chuàng)建進程,Elixir創(chuàng)建進程只需要使用spawn宏即可,sqwan/1 接受一個匿名函數(shù),創(chuàng)建另外一個新進程。

spawn(
    fn ->
        expression_1
        ...
        expression_2
    end
)

修改上面的例子,使用spawn創(chuàng)建一個新進程。可以看到,執(zhí)行spawn之后,馬上返回了函數(shù)調(diào)用的結(jié)果,為新進程新pid。兩秒后,新進程執(zhí)行并打印內(nèi)容返回到iex中。

iex(4)> spawn(fn -> IO.puts run_query.("1") end )
#PID<0.65.0>
1 result

因此可以定義一個異步的查詢函數(shù)并執(zhí)行,可以發(fā)現(xiàn)每次執(zhí)行函數(shù)都馬上返回,而新創(chuàng)建的進程將會在后臺運行,并打印最終結(jié)果:

iex(5)> async_query = fn query_def ->
...(5)>     spawn(fn -> IO.puts run_query.(query_def) end) end
#Function<6.54118792/1 in :erl_eval.expr/5>
iex(6)> async_query.("query 1")
#PID<0.71.0>
query 1 result
iex(7)> 1..5 |> Enum.map(&(async_query.("query #{&1}")))
[#PID<0.73.0>, #PID<0.74.0>, #PID<0.75.0>, #PID<0.76.0>, #PID<0.77.0>]
query 1 result
query 2 result
query 3 result
query 4 result
query 5 result

Elixir中,所有代碼都運行在一個在進程中,iex也是運行在進程中,并且是shell中的主進程。上面的例子中,進程是并發(fā)運行的,因此并不會按照順序輸出結(jié)果。每一個進程都是獨立的,不同的進程是不能讀取對方的數(shù)據(jù)的,而進程之間想要通信需要通過message

進程中的message

通常的語言中,使用多線程進行并發(fā),所有線程共享內(nèi)存數(shù)據(jù)。而elixir提供的是aotor的進程模型。進程通過message同步數(shù)據(jù)。進程A想要讓進程B做點事情,需要A給B的mailbox發(fā)送異步消息,B進程讀取mailbox的消息,解析后執(zhí)行。因為進程見是無法共享內(nèi)存的,因此消息發(fā)送的時候存在著深拷貝(deep-copied)。

發(fā)送信息使用 send函數(shù),接受消息使用 receive do 結(jié)構。 send提供兩個參數(shù),第一個是進程的標識pid,第二個是所需要發(fā)送的數(shù)據(jù)。receive的結(jié)構如下

receive do
  pattern_1 -> do_something
  pattern_2 -> do_something_else
end

receive 結(jié)構和 case 結(jié)構十分相似,也支持mailbox的模式匹配。在iex中,self表示當前的進程,下面使用self來對消息做實驗:

iex(8)> send(self, "a message")
"a message"
iex(9)> receive do
...(9)>   message -> IO.puts message
...(9)> end
a message
:ok
iex(10)> send(self, {:message, 1})
{:message, 1}
iex(11)> receive do
...(11)>   {:message, id} -> IO.puts "received message #{id}"
...(11)> end
received message 1
:ok
iex(13)> receive do
...(13)>    {_, _, _} ->
...(13)>        IO.puts "received"
...(13)> end

send調(diào)用之后會返回發(fā)送的內(nèi)容。由于是自身給自身發(fā)消息,所以可以在當前的進程中調(diào)用receive結(jié)構拉取自身的mailbox的message。如果模式匹配失敗,當前的進程會被block。可以設置一個after分支,當匹配失敗之后,執(zhí)行after的內(nèi)容。

iex(2)> receive do
...(2)>   {_,_,_} -> 'nonthing'
...(2)> after 3000 -> "message not received"
...(2)> end
"message not received"
iex(3)>

receive結(jié)構主要從當前的mailbox中pull數(shù)據(jù)。如果當前消息模式匹配失敗,這個消息將會被放回進程的mailbox之中,僅需讀取下一條消息。總結(jié)receive的工作流如下:

  1. 從mailbox中讀取第一條消息。
  2. 嘗試使用 receive中模式和消息進行模式匹配,從上到下依次進行匹配。
  3. 匹配成功則執(zhí)行對應分支的代碼。
  4. 如果模式匹配失敗,將消息放回原處,接著出現(xiàn)下一條消息。
  5. 如果mailbox的隊列沒有消息了,則等待下一個消息到達,消息一到達,則重復開始第一步。
  6. 如果存在after語句,在等到消息未到到或匹配失敗之后,執(zhí)行after的代碼分支邏輯。

進程通信

通常send消息都是異步執(zhí)行的,進程發(fā)送消息之后就返回,然后當前進程并不知道子進程的執(zhí)行狀況。通常我們需要子進程的執(zhí)行過程,然后子進程將會send消息來反饋主進程。創(chuàng)建了子進程將會返回進程標識pid,基于pid和message可以進行進程間的通信。重寫async_query 函數(shù)并調(diào)用:

iex(1)> run_query = fn(query_def) ->
...(1)>           :timer.sleep(2000)
...(1)>           "#{query_def} result"
...(1)>         end
#Function<6.54118792/1 in :erl_eval.expr/5>
iex(3)> async_query = fn(query_def) ->
...(3)>     caller = self
...(3)>     spawn(fn query_def ->
...(3)>         send(caller, {:query_result, run_query.(query_def)})
...(3)>     end)
...(3)> end
iex(4)> Enum.each(1..5, &async_query.("query #{&1}"))
:ok

新創(chuàng)建的子進程給主進程的mailbox發(fā)送了消息,主進程再把這些消息讀取出來

iex(7)> get_result = fn ->
...(7)>     receive do
...(7)>         {:query_result, result} -> result
...(7)>     end
...(7)> end
iex(10)> get_result.()
"query 1 result"
iex(11)> get_result.()
"query 2 result"
iex(12)> get_result.()
"query 3 result"

客戶端服務端狀態(tài)

通過message和receive可以實現(xiàn)進程間的通信,通常把主進程當成客戶端進程,新創(chuàng)建的進程當成服務端進程。那么cs之間的進程通信會涉及到一下狀態(tài)(state)的操作。所謂的server process 是指一些長時間監(jiān)聽消息的進程,就像服務器進程一樣永遠運行,處于一個無限循環(huán)當中,監(jiān)聽客戶端的消息,處理消息。

receive結(jié)構會將它mailbox,模式匹配不成功的時候會block進程。可是一旦mailbox中的messge消費完了,receive的監(jiān)聽也就結(jié)束了,進程會結(jié)束。因此需要在message消費完畢之后仍然運行進程。使用while結(jié)構很容易實現(xiàn)這樣的程序邏輯,elixir沒有循環(huán),可是有遞歸。

下面以數(shù)據(jù)庫服務為例來做說明。其基本結(jié)構如下:

defmodule DatabaseServer do
    
    def start do
        spawn(&loop/0)
    end

    defp loop do
        receive do
            # pass
        end
        loop
    end

end

DatabaseServer模塊實現(xiàn)了一個服務器循環(huán)進程loop,給客戶端(主進程,調(diào)用者)提供了一個啟動入口start函數(shù)。這個函數(shù)將會創(chuàng)建一個服務端進程,用于監(jiān)聽客戶端的發(fā)送的消息,韓寒處理返回。有人可能有以為,start和loop都是模塊中的函數(shù),分別運行在不同進程中。其實模塊和進程本身沒有特別的關系,模塊就是函數(shù)的集合,這些函數(shù)可以運行在進程中,僅此而已。后期關于類似的實現(xiàn),可以用到更高級的gen_server。

接下來實現(xiàn)loop中的邏輯,以及數(shù)據(jù)庫的服務端和客戶端的查詢方法。

defmodule DatabaseServer do
    
    def start do
        spawn(&loop/0)
    end


    def run_async(server_pid, query_def) do
        send(server_pid, {:run_query, self, query_def})
    end

    defp loop do
        receive do
            
            {:run_query, caller, query_def} -> send(caller, {:query_result, run_query(query_def)})
            
        end
        loop
    end

    defp run_query(query_def) do
        :timer.sleep(2000)
        "#{query_def} result"
    end
end

run_query 為服務端的查詢方法,run_async為客戶端的查詢方法,run_async將查詢信息和自身的pid發(fā)給服務端,服務端匹配之后查詢處理,然后再給客戶端pid發(fā)送查詢結(jié)果。客戶端同樣也使用receive結(jié)構pull查詢結(jié)果:

defmodule DatabaseServer do
    
    def start do
        spawn(&loop/0)
    end

    def run_async(server_pid, query_def) do
        send(server_pid, {:run_query, self, query_def})
    end

    def get_result do
        
        receive do
            {:query_result, result} -> result
        after 5000 ->
            {:error, :timeout}
        end
    end

    defp loop do
        receive do
        
            {:run_query, caller, query_def} -> send(caller, {:query_result, run_query(query_def)})
        
        end
        loop
    end

    defp run_query(query_def) do
        :timer.sleep(2000)
        "#{query_def} result"
    end
end

運行測試

iex(1)> server_pid = DatabaseServer.start
#PID<0.63.0>
iex(2)> DatabaseServer.run_async(server_pid, "query 1")
{:run_query, #PID<0.61.0>, "query 1"}
iex(3)> DatabaseServer.get_result
"query 1 result"
iex(4)> DatabaseServer.run_async(server_pid, "query 2")
{:run_query, #PID<0.61.0>, "query 2"}
iex(5)> DatabaseServer.get_result
"query 2 result"
iex(6)> DatabaseServer.get_result
{:error, :timeout}

把 timer.sleep 改成 10s后, 進程客戶端馬上就返回并且監(jiān)聽服務端的返回,可以服務端異步長時間處理,不能馬上返回,客戶端超時斷開了。第二次調(diào)用get_result的時候,此時服務端已經(jīng)處理完畢,并發(fā)送結(jié)果給客戶端的mailbox。

iex(2)> DatabaseServer.run_async(server_pid, "query 1")
{:run_query, #PID<0.61.0>, "query 1"}
iex(3)> DatabaseServer.get_result
{:error, :timeout}
iex(4)> DatabaseServer.get_result
"query 1 result"

服務端進程都是順序的

盡管實現(xiàn)了服務端進程來處理查詢請求,可是服務端進程監(jiān)聽的是自己進程的mailbos,消費消息卻是順序的。如果客戶端調(diào)用十個查詢請求,服務端同樣需要執(zhí)行10秒。為了避免這樣的情況,一個簡單的處理就是每一個請求實現(xiàn)一個服務端進程,也就是服務端實現(xiàn)一個進程池。面對大量的客戶端就能處理了。等等,你一定以為實現(xiàn)進程池是一個夸張的做法,畢竟直覺上進程的創(chuàng)建和銷毀十分耗資源。感謝Erlang的并發(fā)模式,我們可以在Elixir中輕而易舉的創(chuàng)建大量的進程,這個進程和操作系統(tǒng)進程概念不一樣,它甚至比操作系統(tǒng)的線程還要輕量級。

下面演示進程池的用法:

iex(1)> pool = 1..100 |> Enum.map(fn _ -> DatabaseServer.start end)
[#PID<0.64.0>, #PID<0.65.0>, #PID<0.66.0>, #PID<0.67.0>, #PID<0.68.0>,
 #PID<0.69.0>, #PID<0.70.0>, #PID<0.71.0>, #PID<0.72.0>, #PID<0.73.0>,
 #PID<0.74.0>, #PID<0.75.0>, #PID<0.76.0>, #PID<0.77.0>, #PID<0.78.0>,
 #PID<0.79.0>, #PID<0.80.0>, #PID<0.81.0>, #PID<0.82.0>, #PID<0.83.0>,
 #PID<0.84.0>, #PID<0.85.0>, #PID<0.86.0>, #PID<0.87.0>, #PID<0.88.0>,
 #PID<0.89.0>, #PID<0.90.0>, #PID<0.91.0>, #PID<0.92.0>, #PID<0.93.0>,
 #PID<0.94.0>, #PID<0.95.0>, #PID<0.96.0>, #PID<0.97.0>, #PID<0.98.0>,
 #PID<0.99.0>, #PID<0.100.0>, #PID<0.101.0>, #PID<0.102.0>, #PID<0.103.0>,
 #PID<0.104.0>, #PID<0.105.0>, #PID<0.106.0>, #PID<0.107.0>, #PID<0.108.0>,
 #PID<0.109.0>, #PID<0.110.0>, #PID<0.111.0>, #PID<0.112.0>, #PID<0.113.0>, ...]
iex(2)> 1..5 |>
...(2)>     Enum.each(fn query_def ->
...(2)>         server_pid = Enum.at(pool, :random.uniform(100) - 1)
...(2)>         DatabaseServer.run_async(server_pid, query_def)
...(2)>     end)
:ok
iex(3)> 1..5 |>
...(3)>           Enum.map(fn(_) -> DatabaseServer.get_result end)
["3 result", "5 result", "4 result", "1 result", "2 result"]

運行的結(jié)果中,并沒有超過十秒,而是很快就返回了結(jié)果。

狀態(tài)

設想一下,如果需要跟數(shù)據(jù)庫服務交互的時候,首先當然是需要建立一個連接。連接就必須保持socket能夠正確的工作。因此也需要在進程中保持狀態(tài),可以修改loop函數(shù)實現(xiàn)。

defmodule DatabaseServer do
    
    def start do
        spawn(fn ->
            connection = :random.uniform(1000)
            loop(connection)
        end)
    end

    def run_async(server_pid, query_def) do
        send(server_pid, {:run_query, self, query_def})
    end

    def get_result do
        receive do
            {:query_result, result} -> result
        after 5000 ->
            {:error, :timeout}
        end
    end

    defp loop(connection) do
        receive do  
            {:run_query, from_pid, query_def} -> 
                query_result = run_query(connection, query_def)
                send(from_pid, {:query_result, query_result})

        end
        loop(connection)
    end

    defp run_query(connection, query_def) do
        :timer.sleep(2000)
        "Connection #{connetion}: #{query_def} result"
    end
end

iex(1)> server_pid = DatabaseServer.start
#PID<0.63.0>
iex(2)> DatabaseServer.run_async(server_pid, "query 1")
{:run_query, #PID<0.61.0>, "query 1"}
iex(3)> DatabaseServer.get_result
"Connection 444: query 1 result"
iex(4)> DatabaseServer.run_async(server_pid, "query 2")
{:run_query, #PID<0.61.0>, "query 2"}
iex(5)> DatabaseServer.get_result
"Connection 444: query 2 result"

start 函數(shù)中創(chuàng)建了一些連接,然后loop中把這個狀態(tài)傳遞到進程執(zhí)行代碼的地方。從iex的結(jié)果可以看出,這個狀態(tài)一直被保持,兩次請求服務,都是同一個連接的狀態(tài)。實際的服務器環(huán)境中,往往狀態(tài)不是一層不變的。此時我們需要更新狀態(tài)。一個簡單的技巧就是在loop函數(shù)中更新狀態(tài)。

def loop(state) do
    new_state = receive do      # 捕捉新狀態(tài)
        msg1 -> ...
        msg2 -> ...
    end
    loop(new_state)             # 更新狀態(tài)
end 

下面實現(xiàn)一個計算器服務來闡明狀態(tài)更新技巧。

defmodule Calculator do
    
    def start do
        spawn(fn ->loop(0) end)
    end

    def loop(current_value) do
        new_value = receive do
            {:value, caller} ->
                send(caller, {:response, current_value})
                current_value
            {:add, value} -> current_value + value
            {:sub, value} -> current_value - value
            {:mul, value} -> current_value * value
            {:div, value} -> current_value / value

            invalid_request ->
                IO.puts "invalid request #{inspect invalid_request}"
                current_value
        end
        loop(new_value)
    end

    def value(server_pid) do
        send(server_pid, {:value, self})
        receive do
            {:response, value} -> value
        end
    end

    def add(server_pid, value), do: send(server_pid, {:add, value})
    def sub(server_pid, value), do: send(server_pid, {:sub, value})
    def mul(server_pid, value), do: send(server_pid, {:mul, value})
    def div(server_pid, value), do: send(server_pid, {:div, value})

end

iex(1)> calculator_pid = Calculator.start
#PID<0.63.0>
iex(2)> Calculator.value(calculator_pid)
0
iex(3)> Calculator.add(calculator_pid, 10)
{:add, 10}
iex(4)> Calculator.sub(calculator_pid, 5)
{:sub, 5}
iex(5)> Calculator.mul(calculator_pid, 3)
{:mul, 3}
iex(6)> Calculator.div(calculator_pid, 5)
{:div, 5}
iex(7)> Calculator.value(calculator_pid)
3.0

通常情況下,狀態(tài)遠遠比一個數(shù)字復雜。不過技術手段都是一樣的,只需要在loop函數(shù)中操作狀態(tài)即可。當應用的狀態(tài)變得復雜的時候,是非有必要對代碼進行組織。服務端進程的模塊可以剝離出來專注請求的處理。下面針對之前的todo應用,使用多進程進行改下一下:

defmodule TodoServer do
    
    def start do
        spawn(fn -> loop(TodoList.new) end)
    end

    defp loop(todo_list) do
        new_todo_list = receive do
            message -> process_message(todo_list, message)
        end
        loop(new_todo_list)
    end


    def process_message(todo_list, {:add_entry, new_entry}) do
        TodoList.add_entry(todo_list, new_entry)
    end

    def process_message(todo_list, {:entries, caller, date}) do
        send(caller, {:todo_entries, TodoList.entries(todo_list, date)})
        todo_list
    end

    def add_entry(todo_server, new_entry) do
        send(todo_server, {:add_entry, new_entry})
    end

    def entries(todo_server, date) do
        send(todo_server, {:entries, self, date})
        receive do
            {:todo_entries, entries} -> entries
        after 5000 ->
            {:error, :timeout}
        end
    end
end

調(diào)用方式如下:

iex(1)> todo_server = TodoServer.start
#PID<0.66.0>
iex(2)> TodoServer.add_entry(todo_server,
...(2)>                   %{date: {2013, 12, 19}, title: "Dentist"})
{:add_entry, %{date: {2013, 12, 19}, title: "Dentist"}}
iex(3)> TodoServer.entries(todo_server, {2013, 12, 19})
[%{date: {2013, 12, 19}, id: 1, title: "Dentist"}]
iex(8)> TodoServer.add_entry(todo_server,
...(8)>                   %{date: {2013, 12, 20}, title: "Shopping"})
{:add_entry, %{date: {2013, 12, 20}, title: "Shopping"}}
iex(9)> TodoServer.entries(todo_server, {2013, 12, 19})
[%{date: {2013, 12, 19}, id: 1, title: "Dentist"}]
iex(10)> TodoServer.entries(todo_server, {2013, 12, 20})
[%{date: {2013, 12, 20}, id: 2, title: "Shopping"}]
iex(11)> TodoServer.add_entry(todo_server,
...(11)>                   %{date: {2013, 12, 19}, title: "Movies"})
{:add_entry, %{date: {2013, 12, 19}, title: "Movies"}}
iex(12)> TodoServer.entries(todo_server, {2013, 12, 19})
[%{date: {2013, 12, 19}, id: 3, title: "Movies"},
 %{date: {2013, 12, 19}, id: 1, title: "Dentist"}]

這樣的調(diào)用方式,客戶端需要知道開啟的后臺進程號。如果這個過程隱藏在模塊中,豈不是更簡潔。elixir提供了Process 模塊的register函數(shù),實現(xiàn)了針對進程的設置別名的應用。

其用法如下:

iex(1)> Process.register(self, :some_name)
iex(2)> send(:some_name, :msg)
iex(3)> receive do
          msg -> IO.puts "received #{msg}"
        end
received msg

修改TodoServer如下:

defmodule TodoServer do
    
    def start do
        pid = spawn(fn -> loop(TodoList.new) end)
        Process.register(pid, :todo_server)
    end

    defp loop(todo_list) do
        new_todo_list = receive do
            message -> process_message(todo_list, message)
        end
        loop(new_todo_list)
    end


    def process_message(todo_list, {:add_entry, new_entry}) do
        TodoList.add_entry(todo_list, new_entry)
    end

    def process_message(todo_list, {:entries, caller, date}) do
        send(caller, {:todo_entries, TodoList.entries(todo_list, date)})
        todo_list
    end

    def add_entry(new_entry) do
        send(:todo_server, {:add_entry, new_entry})
    end

    def entries(date) do
        send(:todo_server, {:entries, self, date})
        receive do
            {:todo_entries, entries} -> entries
        after 5000 ->
            {:error, :timeout}
        end
    end
end

調(diào)用方式如下:

iex(1)> TodoServer.start
true
iex(2)> TodoServer.add_entry(%{date: {2013, 12, 19}, title: "Dentist"})
{:add_entry, %{date: {2013, 12, 19}, title: "Dentist"}}
iex(3)> TodoServer.add_entry(%{date: {2013, 12, 20}, title: "Shopping"})
{:add_entry, %{date: {2013, 12, 20}, title: "Shopping"}}
iex(4)> TodoServer.add_entry(%{date: {2013, 12, 19}, title: "Movies"})
{:add_entry, %{date: {2013, 12, 19}, title: "Movies"}}
iex(5)> TodoServer.entries({2013, 12, 19})
[%{date: {2013, 12, 19}, id: 3, title: "Movies"},
 %{date: {2013, 12, 19}, id: 1, title: "Dentist"}]

可見,后臺執(zhí)行任務的進程,相對客戶端被隱藏啦。

最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內(nèi)容

  • Spring Cloud為開發(fā)人員提供了快速構建分布式系統(tǒng)中一些常見模式的工具(例如配置管理,服務發(fā)現(xiàn),斷路器,智...
    卡卡羅2017閱讀 134,993評論 19 139
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 173,596評論 25 708
  • Linux 進程管理與程序開發(fā) 進程是Linux事務管理的基本單元,所有的進程均擁有自己獨立的處理環(huán)境和系統(tǒng)資源,...
    JamesPeng閱讀 2,512評論 1 14
  • Jianwei's blog 首頁 分類 關于 歸檔 標簽 巧用Android多進程,微信,微博等主流App都在用...
    justCode_閱讀 5,973評論 1 23
  • ? 泉州后城文化街96號,一道狹窄的厝門讓路人覺得毫不起眼。可是誰也沒想到,毫不起眼的厝門里竟深藏著一座氣勢恢弘、...
    福茶之心閱讀 740評論 0 0