上一篇文章使用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”