(二)透過fork編寫一個簡單的併發伺服器

昀溪發表於2018-09-23

概述

那麼最簡單的服務端併發處理客戶端請求就是,父程式用監聽套接字監聽,當有連線過來時那麼監聽套接字就變成了已連線套接字(源和目的的IP和埠都包含了),這時候就可以和客戶端通訊,但此時其他客戶端無法連線進來,因為這個套接字被佔用,所以就會產生一個子程式來處理和客戶端的通訊,也就是這個連線套接字由子程式處理,而父程式繼續用監聽套接字的形式來等待下一個連線請求。

程式碼段

伺服器程式

 1 #!/usr/bin/env python
 2 # -*- coding: utf-8 -*-
 3 # Author: rex.cheny
 4 # E-mail: rex.cheny@outlook.com
 5 
 6 import socket
 7 import os, time, sys
 8 
 9 
10 def echoStr(connFd):
11     print("新連線:", connFd.getpeername())
12     while True:
13         bytesData = connFd.recv(1024)
14         data = bytesData.decode(encoding="utf-8")
15         print("收到客戶端訊息:", data)
16         if data == "Bye":
17             return
18         else:
19             time.sleep(1)
20             connFd.send(data.encode(encoding="utf-8"))
21 
22 
23 def main():
24     sockFd = socket.socket()
25     sockFd.bind(("", 5555))
26     sockFd.listen(5)
27 
28     print("等待客戶端連線......")
29     while True:
30         connFd, remAddr = sockFd.accept()
31 
32         try:
33             pid = os.fork()
34             if pid == 0:
35                 # 說明當前執行在子程式中
36                 sockFd.close()  # 關閉監聽套接字
37                 echoStr(connFd)  # 執行回顯函式
38                 # connFd.close()  # 關閉連線套接字,這裡是否要顯示的關閉和客戶端的連線套接字與個人程式設計風格有關,因為客戶端傳送完資料後就主動呼叫了close()
39                 exit(0)  # 子程式退出
40             else:
41                 """
42                 關閉連線套接字,這時候並不會關閉伺服器與客戶端的TCP連線,因為connFd這個套接字的會被子程式所使用,所以該套接字的引用
43                 計數器為1,只有為0時才會被關閉。
44                 """
45                 connFd.close()
46         except Exception as err:
47             print(err)
48 
49 
50 if __name__ == '__main__':
51     main()

客戶端程式

 1 #!/usr/bin/env python
 2 # -*- coding: utf-8 -*-
 3 # Author: rex.cheny
 4 # E-mail: rex.cheny@outlook.com
 5 
 6 import socket
 7 
 8 
 9 def echoStr(sockFd, data):
10     sockFd.send(data)
11     bytesData = sockFd.recv(1024)
12     data = bytesData.decode(encoding="utf-8")
13     print(data)
14 
15 
16 def main():
17     sockFd = socket.socket()
18     sockFd.connect(("127.0.0.1", 5555))
19 
20     for i in range(1, 11):
21         data = "第:" + str(i) + " 條訊息。"
22         echoStr(sockFd, data.encode(encoding="utf-8"))
23 
24     echoStr(sockFd, "Bye".encode(encoding="utf-8"))
25     sockFd.close()
26 
27 
28 if __name__ == '__main__':
29     main()

結果演示

 

這時候就實現了多個客戶端併發連線伺服器端。這裡只是演示了一種最簡單也是最原始的一種方式,因為fork一個程式系統開銷很大所以雖然是併發但是不適用大規模併發的情況下。無論是多程式或者是程式池或者是多執行緒併發其實效率都不高當然這是相對來講,你想一想1000個請求難道要啟動1000個執行緒或者程式嗎?顯然不現實。在面對大量併發請求的時候就要用到多路複用或者是非同步,這個後面章節會講。不過需要先明白多路複用和併發處理其實是兩個概念,多路複用主要解決不阻塞問題而併發是同時處理問題,我上面這種FORK的形式雖然是併發的,但是單一程式內其實還是阻塞的,無論是對伺服器程式還是客戶端程式它們內部都是阻塞的。

我們這次透過後臺執行檢視一下網路和程式狀態。

# 這樣來執行伺服器程式
python3 ./server.py >> ./log.txt &

當有客戶端連線進來是這樣的,子程式PID是65686而它的PID也就是父程式ID是65623;網路監控顯示有一個TCP連線成功建立

當客戶端執行完畢之後是這樣的,伺服器顯示的連線套接字時TIME_WAIT狀態,而之前fork的子程式變成了殭屍程式。TIME_WAIT過一會兒就消失掉,但是這個殭屍程式會一直存在,直到父程式退出。

殭屍程式

上面最後一個圖中的 “Z” 就表示殭屍程式。

接下來我們改進一下伺服器程式來解決一下殭屍程式問題,客戶端不變。

 1 #!/usr/bin/env python
 2 # -*- coding: utf-8 -*-
 3 # Author: rex.cheny
 4 # E-mail: rex.cheny@outlook.com
 5 
 6 import socket
 7 import os, time, sys
 8 import signal
 9 
10 
11 def echoStr(connFd):
12     print("新連線:", connFd.getpeername())
13     while True:
14         bytesData = connFd.recv(1024)
15         data = bytesData.decode(encoding="utf-8")
16         print("收到客戶端訊息:", data)
17         if data == "Bye":
18             return
19         else:
20             time.sleep(1)
21             connFd.send(data.encode(encoding="utf-8"))
22 
23 
24 def sigChld(signum, frame):
25     """
26     殭屍程式處理函式這兩個引數是必須的
27     :param signum: 發生的訊號
28     :param frame: 發生訊號的時候的函式呼叫棧
29     :return:
30     """
31 
32     """
33     waitpid() 用於清理殭屍程式,返回值為已終止的子程式ID和程式終止狀態
34     pid 程式號,如果是-1表示等待第一個終止的子程式
35     os.WNOHANG 引數,預設為0也就是os.WNOHANG,表示在核心沒有通知有已終止的子程式時不阻塞
36     """
37     pid, status = os.waitpid(-1, os.WNOHANG)
38     while pid > 0:
39         print("child ", pid, " is terminated")
40         return
41 
42 
43 def main():
44     sockFd = socket.socket()
45     sockFd.bind(("", 5555))
46     sockFd.listen(5)
47 
48     """
49     呼叫signal函式,第一個引數是訊號名,第二個是訊號處理函式。這個signal函式就是對系統呼叫signal函式的簡單封裝。
50     這個函式必須在fork第一個子程式之前做且只能做一次。
51     """
52     signal.signal(signal.SIGCHLD, sigChld)
53     print("等待客戶端連線......")
54     while True:
55         connFd, remAddr = sockFd.accept()
56 
57         try:
58             pid = os.fork()
59             if pid == 0:
60                 # 說明當前執行在子程式中
61                 sockFd.close()  # 關閉監聽套接字
62                 echoStr(connFd)  # 執行回顯函式
63                 # connFd.close()  # 關閉連線套接字,這裡是否要顯示的關閉和客戶端的連線套接字與個人程式設計風格有關,因為客戶端傳送完資料後就主動呼叫了close()
64                 exit(0)  # 子程式退出
65             else:
66                 """
67                 關閉連線套接字,這時候並不會關閉伺服器與客戶端的TCP連線,因為connFd這個套接字的會被子程式所使用,所以該套接字的引用
68                 計數器為1,只有為0時才會被關閉。
69                 """
70                 connFd.close()
71         except Exception as err:
72             print(err)
73 
74 
75 if __name__ == '__main__':
76     main()

SIGCHLD訊號的含義是一個程式終止或者停止,將該訊號傳送給父程式,父程式的wait或者waitpid可以捕捉這個訊號。

這時候可以看到通訊完畢後已經不存在殭屍程式了。

殭屍程式會被init或者systemd程式接管,而他們會使用wait來清理這些殭屍程式,所以當我們使用fork子程式的時候都要wait他們防止他們變成殭屍程式。wait和waitpid都可以清理殭屍程式,但是略有不同:

  • 呼叫wait的時候如果沒有已經終止的子程式那麼呼叫wait的程式將會被阻塞在這裡直到出現一個被終止的子程式
  • 呼叫waitpid我們可以透過引數指定pid號也可以指定如果沒有已經終止的子程式是否要阻塞,這樣體驗就會好很多。

如果你這裡呼叫wait將會發生什麼呢?已上面的程式為例你同時啟動5個客戶端,在伺服器就會fork5個子程式處理,當通訊完畢需要清理殭屍程式的時候有很大可能會清理小於5個。因為5箇中斷訊號幾乎同時到達,第一個到達的時候阻塞在wait,而後面相繼到達,Unix的訊號是不排隊的,也就是被阻塞期間產生一次或多次最終只提交一次。

Python自帶的一個叫做socketserver模組裡面有一個ForkingMixIn的其核心實現就是fork加訊號處理。

需要知道的事情

伺服器程式崩潰會怎麼樣

與伺服器之間的網路中斷會怎麼樣

 

相關文章