Python的非阻塞式(non-blocking)socket通訊程式(二):使用select程式庫

上一篇文章使用try…except包圍所有socket相關敘述,本文將採用被廣泛用在socket通訊程式的select程式庫,解決socket阻塞問題。

select程式庫的select()函式,可接收來自作業系統的socket狀態訊息,每當有資料輸入或者準備好要輸出時,Python程式碼就會收到通知。select()不僅能偵聽socket狀態,在UNIX/Linux系統上也可以偵聽檔案讀寫、使用者在終端機的輸入操作…等所有可傳回有效「檔案描述符」(file descriptor,相當於檔案的識別碼)的物件。

select()函式接收3個列表(list)類型參數,並傳回3個列表值,語法如下:

select()函式語法說明

select()函式將不停地檢查輸入、輸出和例外錯誤三個列表的物件,若其中任何物件有動靜,例如,「輸入列表」裡的伺服器socket有新的連線請求,它將傳回該物件,讓底下的程式碼處理後續作業,例如,回應連線請求。

假如沒有設定超時秒數參數,select()函式將阻塞程式流程,直到上述任一「偵聽對象」有動靜,才會繼續往下執行。

首先使用select()函式改寫《超圖解Python物聯網實作入門》17-2頁的MicroPython網站伺服器程式,請在開頭引用select模組,並在程式中宣告一個將用於select()函式的「輸入」物件列表:

import select
import socket

server = socket.socket()
HOST = '0.0.0.0'
PORT = 80
httpHeader = b"""\
HTTP/1.0 200 OK

Welcome to MicroPython!
"""

server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((HOST, PORT))
server.setblocking(0)  # socket設成「非阻塞」模式
server.listen(5)
inputs = [server]      # 宣告「輸入」列表,存入伺服器socket。

print("Server running on port ", PORT)

def hello():         # 將被不停執行的自訂函式
    print('hello!')

改寫後的主程式迴圈如下,網站伺服器程式只需要讓select()偵聽「輸入」物件的狀態,「輸出」和「例外錯誤」兩個列表都設成空白;「超時秒數」參數設成1,代表假如1秒鐘之內,「輸入」列表裡的物件都沒有動靜,就解除阻塞,繼續往下執行。因此,這個迴圈將使得最後一行的hello()函式每隔1秒被呼叫一次。「超時秒數」可以設定成很小的正浮點數,例如0.5或者0(代表「輸入」沒有動靜就立即往下執行)。

select()函式運作流程

每當「輸入」列表裡的物件(初始內容僅有伺服器socket物件)收到資料,select()就會解除阻塞,進入上面程式編號3所在的for迴圈。此時,readable列表裡面將包含伺服器socket物件,因此if條件成立,上圖編號4那一行,將建立一個連結用戶端的socket物件。

新建立的用戶端socket也要交給select()函式偵聽動靜,所以編號5那一行將它附加到inputs列表,這時的inputs列表包含兩個socket物件。

當用戶端socket收到來自瀏覽器的HTTP請求訊息時,select()將再次傳回「有資料輸入」的socket物件,這一次是用戶端socket,所以for迴圈裡的else區塊被執行,傳送HTTP回應給瀏覽器,然後關閉socket連線。

用戶端socket與瀏覽器的連線關閉了,也就不會再收到訊息,因此編號7那一行將它從inputs列表中刪除。日後若有新的HTTP連線請求,同樣會由伺服器socket先收到資料,再動態建立新的用戶端socket來服務該用戶(參閱上一篇文章說明)。

底下是修改後的diy17_1.py完整程式碼,採用select()函式處理非阻塞socket通訊:

import select
import socket

server = socket.socket()
HOST = '0.0.0.0'
PORT = 80
httpHeader = b"""\
HTTP/1.0 200 OK

Welcome to MicroPython!
"""

server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((HOST, PORT))
server.setblocking(0)  # socket設成「非阻塞」模式
server.listen(5)
inputs = [server]      # 宣告「輸入」列表,存入伺服器socket。

print("Server running on port ", PORT)


def hello():
    print('hello!')

while True:
    readable, _, _ = select.select(inputs, [], [], 1)

    for sck in readable:
        if sck is server:
            client, addr = sck.accept()
            client.setblocking(0)
            inputs.append(client)
        else:
            req = sck.recv(1024)
            sck.send(httpHeader)
            sck.close()
            inputs.remove(sck)

    hello()

把這個程式檔上傳到Wemos D1 mini或其他燒錄MicroPython的ESP8266控制板後,透過終端機執行它的結果如下,它將每隔1秒顯示‘hello!’,代表socket並沒有造成阻塞,而且它也可以回應瀏覽器的連線請求。

使用select函式的socket程式運作結果

使用select()改寫存取網頁檔案的MicroPython網站伺服器程式

我們可以用相同手法改寫17-14頁的http_file.py檔,同樣先在開頭引用select模組,筆者修改了err()函式,調整它的動態字串格式並加上捕捉例外錯誤,以防在傳送回應時用戶端先行離線而引發錯誤。

import select
import socket
import os
import gc

def err(socket, code, msg):
    rsp = b'''\
    HTTP/1.0 {code}
    Content-type:text/html

    <h1>{msg}</h1>

    '''
    try:
        socket.write(rsp.format(code=code, msg=msg))
    except:
        print('response error!')

讀取與傳送網頁資源的writeFile()函式,請將讀取檔案的緩衝記憶體從64位元組改成1位元組,若不修改,改用select()函式處理socket的Wemos D1 mini控制板可能無法讀取完整的檔案,例如,假設圖檔為7.4KB,它可能只讀到6.3KB就回報讀取完畢。同樣地,為了避免用戶端在檔案傳送完畢之前斷線而引發錯誤,client.write(chunk)敘述也用try…except包圍。

def writeFile(client, fileName):
    contentType = checkMimeType(fileName)

    if contentType:
        fileSize = checkFileSize(WWWROOT+fileName)

        if fileSize != None:
            header = httpHeader.format(contentType, fileSize)
            client.write(header.encode('utf-8'))

            with open(WWWROOT+fileName, 'rb') as f:
                while True:
                    chunk = f.read(1) # 原本是read(64)
                    if not chunk:     # 等同這個寫法:if chunk == b'':
                        break
                    try:
                        client.write(chunk)
                    except OSError:
                        print('client disconnected?')
                        break
        else:
            err(client, "404", "Not Found")
    else:
        err(client, "415", "Unsupported Media Type")

def hello():
    print('hello!')

最後要修改地方的是main()主函式,為了偵聽並處理socket可能引發的錯誤,select()函式的「例外錯誤」列表參數設定成inputs,當其中的任一socket引發例外錯誤時,該socket將被傳入err變數。

def main():
    server = socket.socket()
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind((HOST, PORT))
    server.listen(5)
    server.setblocking(0)
    inputs = [server]     # 提供給select()的「輸入」列表
    print('Web server running on port', PORT)

    while True:
        # 偵聽「輸入」列表可能引發的錯誤
        readable, _, err = select.select(inputs, [], inputs, 1)

        for s in readable:
            if s is server:
                client = s.accept()[0]
                client.setblocking(0)
                inputs.append(client)
            else:
                handleRequest(s)
                s.close()
                inputs.remove(s)
                print('Free RAM before GC:', gc.mem_free())
                gc.collect()
                print('Free RAM after GC:', gc.mem_free())

        # 若socket發生例外錯誤,就令它斷線並刪除。
        for s in err:
            print('Socket ERROR!')
            inputs.remove(s)
            s.close()

        hello()

if __name__ == '__main__':
    main()

其餘程式碼不用改,上傳並執行此http_file.py檔,終端機將每隔1秒顯示‘hello!’,而且它也可以回應瀏覽器的連線請求。

Posts created 468

One thought on “Python的非阻塞式(non-blocking)socket通訊程式(二):使用select程式庫

  1. 想請教一下,若正在輸入但還沒完成時,有一筆資料進來要print出來會直接輸出在剛輸入到一半還沒input的那一行,希望能知道如何解決這樣的視窗問題,謝謝。
    例如:當Alex正在輸入 “hi, “準備要傳給Amy時,若Amy此時傳了”Hi, Alex” 則在Alex的視窗會顯示 “hi, Hi Alex”

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

Related Posts

Begin typing your search term above and press enter to search. Press ESC to cancel.

Back To Top