python的socket.recv函式陷阱

zlw10100發表於2018-08-04

目錄

前言

慣例練習歷史實驗,在編寫tcp資料流粘包實驗的時候,發現一個奇怪的現象。當遠端執行的命令返回結果很短的時候可以正常執行,但返回結果很長時,就會發生json解碼錯誤,故將排錯和解決方法記錄下來。

一個粘包實驗

服務端(用函式):

import socket
import json
import struct
import subprocess
import sys

from concurrent.futures import ThreadPoolExecutor

def init_socket():
    addr = (`127.0.0.1`, 8080)
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.bind(addr)
    server.listen(5)
    print(`start listening...`)
    return server


def handle(request):
    command = request.decode(`utf-8`)
    obj = subprocess.Popen(command,
                           shell=True,
                           stdout=subprocess.PIPE,
                           stderr=subprocess.PIPE)
    result = obj.stdout.read() + obj.stderr.read()
    # 如果是win還需要轉換編碼
    if sys.platform == `win32`:
        result = result.decode(`gbk`).encode(`utf-8`)
    return result


def build_header(data_len):
    dic = {
        `cmd_type`: `shell`,
        `data_len`: data_len,
    }
    return json.dumps(dic).encode(`utf-8`)


def send(conn, response):
    data_len = len(response)
    header = build_header(data_len)
    header_len = len(header)
    struct_bytes = struct.pack(`i`, header_len)

    # 粘包傳送
    conn.send(struct_bytes)
    conn.send(header)
    conn.send(response)


def task(conn):
    try:
        while True:  # 訊息迴圈
            request = conn.recv(1024)
            if not request:
                # 連結失效
                raise ConnectionResetError

            response = handle(request)
            send(conn, response)

    except ConnectionResetError:
        msg = f`連結-{conn.getpeername()}失效`
        conn.close()
        return msg


def show_res(future):
    result = future.result()
    print(result)


if __name__ == `__main__`:
    max_thread = 5
    futures = []
    server = init_socket()

    with ThreadPoolExecutor(max_thread) as pool:
        while True:  # 連結迴圈
            conn, addr = server.accept()
            print(f`一個客戶端上線{addr}`)

            future = pool.submit(task, conn)
            future.add_done_callback(show_res)
            futures.append(future)

客戶端(用類):

import socket
import struct
import time
import json

class Client(object):
    addr = (`127.0.0.1`, 8080)

    def __init__(self):
        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.socket.connect(self.addr)
        print(`連線上伺服器`)

    def get_request(self):
        while True:
            request = input(`>>>`).strip()
            if not request:
                continue

            return request

    def recv(self):
        # 拆包接收
        struct_bytes = self.socket.recv(4)
        header_len = struct.unpack(`i`, struct_bytes)[0]
        header_bytes = self.socket.recv(header_len)
        header = json.loads(header_bytes.decode(`utf-8`))
        data_len = header[`data_len`]

        gap_abs = data_len % 1024
        count = data_len // 1024
        recv_data = b``

        for i in range(count):
            data = self.socket.recv(1024)
            recv_data += data
        recv_data += self.socket.recv(gap_abs)

        print(`recv data len is:`, len(recv_data))
        return recv_data

    def run(self):
        while True:  # 訊息迴圈
            request = self.get_request()
            self.socket.send(request.encode(`utf-8`))
            response = self.recv()
            print(response.decode(`utf-8`))


if __name__ == `__main__`:
    client = Client()
    client.run()

執行結果

在執行dir/ipconfig等命令時可以正常獲取結果,但是在執行tasklist命令時,發現沒有獲取完整的執行結果,而且下一條命令將發生報錯:

Traceback (most recent call last):
  File "F:/projects/hello/world.py", line 62, in <module>
    client.run()
  File "F:/projects/hello/world.py", line 57, in run
    response = self.recv()
  File "F:/projects/hello/world.py", line 35, in recv
    header = json.loads(header_bytes.decode(`utf-8`))
  File "C:UserszouliweiAppDataLocalProgramsPythonPython36libjson\__init__.py", line 354, in loads
    return _default_decoder.decode(s)
  File "C:UserszouliweiAppDataLocalProgramsPythonPython36libjsondecoder.py", line 339, in decode
    obj, end = self.raw_decode(s, idx=_w(s, 0).end())
  File "C:UserszouliweiAppDataLocalProgramsPythonPython36libjsondecoder.py", line 357, in raw_decode
    raise JSONDecodeError("Expecting value", s, err.value) from None
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

排錯思路

1、錯誤明確指示是json的解碼發生了錯誤,解碼錯誤應該是來自於解碼的資料編碼不正確或者讀取的資料不完整
2、發生錯誤的函式在客戶端,錯誤在第6行,摘出如下:

 def recv(self):
        # 拆包接收
        struct_bytes = self.socket.recv(4)
        header_len = struct.unpack(`i`, struct_bytes)[0]
        header_bytes = self.socket.recv(header_len)
        header = json.loads(header_bytes.decode(`utf-8`))  # 此行發生錯誤
        data_len = header[`data_len`]

        gap_abs = data_len % 1024
        count = data_len // 1024
        recv_data = b``

        for i in range(count):
            data = self.socket.recv(1024)
            recv_data += data
        recv_data += self.socket.recv(gap_abs)

        print(`recv data len is:`, len(recv_data))
        return recv_data

3、繼續思考,第6行嘗試對接收到的頭部二進位制資料進行json解碼,而頭部二進位制在伺服器是通過UTF-8編碼的,檢視伺服器端編碼程式碼發現沒有錯誤,所以編碼錯誤被排除。剩下的應該就是接收的資料不完整問題。
4、按理說,通過structheader來控制每一次讀取的位元組流可以保證每次收取的時候是準確完整的收取一個訊息的資料,但是這裡卻發生了錯誤,我通過在下方的for函式增加print看一下依次迴圈讀取時的長度資料:

for i in range(count):
    data = self.socket.recv(1024)
    print(`recv接收的長度是:`, len(data))  # 增加此行檢視每次迴圈讀取的長度是多少,按理應該是1024
    recv_data += data

結果令我意外:

recv接收的長度是: 1024
recv接收的長度是: 1024
recv接收的長度是: 1024
recv接收的長度是: 1024
recv接收的長度是: 400  # 錯誤
recv接收的長度是: 1024
recv接收的長度是: 1024
recv接收的長度是: 1024
recv接收的長度是: 400  # 錯誤
recv接收的長度是: 1024
recv接收的長度是: 1024
recv接收的長度是: 1024
recv接收的長度是: 400  # 錯誤
recv接收的長度是: 1024
recv接收的長度是: 1024
recv data len is: 14121

按照邏輯,每一次迴圈應該都收取1024位元組,卻發現有3次收取並不完整(每次執行時錯誤不完全一樣,但是都會發生錯誤),這就是導致最終資料不完整的原因。
因為執行tasklist返回的結果很長,導致接收資料不完整,於是下一條執行命令就發生了粘包,json解碼的資料就不是一個正常的資料,故報錯。

解決和總結

1、之所以會發生這種情況,我猜測應該是recv函式的接收機制原因,recv函式一旦被呼叫,就會嘗試獲取緩衝中的資料,只要有資料,就會直接返回,如果緩衝中的資料大於1024,最多返回1024位元組,不過如果緩衝只有400,也只會返回400,這是recv函式的讀取機制。

2、當客戶端需要讀取大量資料(執行tasklist命令的返回就達到1w位元組以上)時,需要多次recv,每一次recv時,客戶端並不能保證緩衝中的資料量已經達到1024位元組(這可能有伺服器和客戶端傳送和接收速度不適配的問題),有可能某次緩衝只有400位元組,但是recv依然讀取並返回。

3、最初嘗試解決的方法是,在recv之前增加time.sleep(0.1)來使得每次recv之前都有一個充足的時間來等待緩衝區的資料大於1024,此方法可以解決問題,不過這方法不是很好,因為如果伺服器在遠端,就很難控制sleep的秒數,因為你不知道網路IO會發生多長時間,一旦sleep時間過長,就會長期阻塞執行緒浪費cpu時間。

4、檢視recv函式原始碼,發現是c寫的,不過recv的介面好像除了size之外,還有一個flag引數。翻看《python參考手冊》查詢recv函式的說明,recv函式的flag引數可以有一個選項是:MSG_WAITALL,書上說,這表示在接收的時候,函式一定會等待接收到指定size之後才會返回。

5、最終使用如下方法解決:

for i in range(count):
    # time.sleep(0.1)
    data = self.socket.recv(1024, socket.MSG_WAITALL)
    print(`recv接收的長度是:`, len(data))
    recv_data += data

接收結果:

recv接收的長度是: 1024
recv接收的長度是: 1024
recv接收的長度是: 1024
recv接收的長度是: 1024
recv接收的長度是: 1024
recv接收的長度是: 1024
recv接收的長度是: 1024
recv接收的長度是: 1024
recv接收的長度是: 1024
recv接收的長度是: 1024
recv接收的長度是: 1024
recv接收的長度是: 1024
recv接收的長度是: 1024
recv接收的長度是: 1024
recv接收的長度是: 1024
recv data len is: 16039

6、以後應該還會學習到更好的解決方法,努力學習。


相關文章