上一篇文章使用try…except包圍所有socket相關敘述,本文將採用被廣泛用在socket通訊程式的select程式庫,解決socket阻塞問題。
select程式庫的select()函式,可接收來自作業系統的socket狀態訊息,每當有資料輸入或者準備好要輸出時,Python程式碼就會收到通知。select()不僅能偵聽socket狀態,在UNIX/Linux系統上也可以偵聽檔案讀寫、使用者在終端機的輸入操作…等所有可傳回有效「檔案描述符」(file descriptor,相當於檔案的識別碼)的物件。
select()函式接收3個列表(list)類型參數,並傳回3個列表值,語法如下:
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(代表「輸入」沒有動靜就立即往下執行)。
每當「輸入」列表裡的物件(初始內容僅有伺服器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()改寫存取網頁檔案的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!’,而且它也可以回應瀏覽器的連線請求。
想請教一下,若正在輸入但還沒完成時,有一筆資料進來要print出來會直接輸出在剛輸入到一半還沒input的那一行,希望能知道如何解決這樣的視窗問題,謝謝。
例如:當Alex正在輸入 “hi, “準備要傳給Amy時,若Amy此時傳了”Hi, Alex” 則在Alex的視窗會顯示 “hi, Hi Alex”