[Python]再學 socket 之非阻塞 Server


再學 socket 之非阻塞 Server

本文是基於 python2.7 實現,運行於 Mac 系統下

本篇文章是上一篇初探 socket 的續集,
上一篇文章介紹了:如何建立起一個基本的 socket 連接、TCP 和 UDP 的概念、socket 常用參數和方法

Socket 是用來通信、傳輸數據的對象,上一篇已經研究了如果進行基本的通行和傳輸數據。因為,在這個互
聯網爆發的時代,做為 Server 的 socket 要同時接收很多的請求。

通過閱讀:地址,強烈推薦閱讀原文。

整理了下面的文字,如何:創建一個 非阻塞的 server。

一、阻塞 Server

  • 阻塞 Server 示例
  • 為什么會出現阻塞

1.1 阻塞 Server 示例

下面就通過C/S模型,展示阻塞狀態:

  • 接收其它 socket 請求的 socket 叫做:Server(S)
  • 請求 Server 的 socket 叫做:Client(C)

該代碼片段分別是:阻塞的 Server 和測試用的 Client:

#!/usr/bin/env python
# -*- coding:utf-8 -*-
#   
#   Author  :   XueWeiHan
#   Date    :   17/2/25 上午10:39
#   Desc    :   阻塞 server
import socket
import time

SERVER_ADDRESS = (HOST, PORT) = '', 50007
REQUEST_QUEUE_SIZE = 5


def handle_request(client_connection):
    """
    處理請求
    """
    request = client_connection.recv(1024)
    print('Server recv: {request_data}'.format(request_data=request.decode()))
    time.sleep(10)  # 模擬阻塞事件
    http_response = "Hello, I'm server"
    client_connection.sendall(http_response)


def server():
    listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    listen_socket.bind(SERVER_ADDRESS)
    listen_socket.listen(REQUEST_QUEUE_SIZE)
    print('Server on port {port} ...'.format(port=PORT))

    while 1:
        client_connection, client_address = listen_socket.accept()
        handle_request(client_connection)
        client_connection.close()

if __name__ == '__main__':
    server()

  • REQUEST_QUEUE_SIZE:在 sever 阻塞的時,允許掛起幾個連接。便於可以處理時直接從該隊列中取得連接,減少建立連接的時間
  • time.sleep:用於模擬阻塞

測試用的 Client

#!/usr/bin/env python
# -*- coding:utf-8 -*-
#   
#   Author  :   XueWeiHan
#   Date    :   17/2/25 上午11:13
#   Desc    :   測試 client

import socket

SERVER_ADDRESS = (HOST, PORT) = '', 50007


def send_message(s, message):
    """
    發送請求
    """
    s.sendall(message)


def client():
    message = "Hello, I'm client"
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect(SERVER_ADDRESS)
    send_message(s, message)
    print 'Client is Waiting response...'
    data = s.recv(1024)
    s.close()
    print 'Client recv:', repr(data)  # 打印從服務器接收回來的數據

if __name__ == '__main__':
    client()

打開三個終端,先運行 Server,在另外兩個終端運行 Client(分別起名為client1、client2),會發現
服務器先接收 client1 的數據,然后返回響應。再此之前 client2 一直處於等待的狀態。只有等 Server
處理完 client1 的請求后,才會接收 client2 的數據。

這樣一個個地接收請求、處理請求的 Server 就叫做 阻塞 Server。

1.2 為什么會出現阻塞?

因為服務器處理請求是需要消耗時間的,正如我上面的阻塞 Server 代碼中的time.sleep(10),用於模擬
服務器處理請求消耗的時間。

在處理完上一個請求(返回給 Client 數據)的這段時間中,服務器無法處理其它的請求,只能讓其它的 Client 等待。這樣的效率是
極其低下的,所以下面就介紹如何創建一個非阻塞的 Server

二、非阻塞 Server

  • 需要知道的一些基本概念
  • 非阻塞 Server 示例(多進程)

后面會用多進程實現 非阻塞socket,在此之前需要了解一些基本知識和概念,便於理解后面的代碼。

2.1 需要知道的一些基本概念

  • Socket 處理請求的過程
  • 進程
  • 文件描述符
  • 如何查看進程和用戶資源

2.1.1 Socket 處理請求的過程

參照上面寫的阻塞 Server 的代碼,可以看出:服務器端的socket對象,listen_socket 從不和客戶端交換數據。它只會通過accept方法接受連接。然后,創建一個新的socket對象,client_connection用於和客戶端通信。

所以,服務器端的socket 分為:接受請求的socket(listen_socket)與客戶端傳輸數據的socket(client_connection)

正如上面說到的,真正阻塞地方是:與客戶端傳輸數據的socket(client_connection) 需要等待處理請求的結果,然后返還給客戶端,結束這次通信,才能處理后面的請求。

2.1.2 進程

存在硬盤中的叫做‘程序’(*.py),當程序運行加載到內存中的時候叫做‘進程’。系統會分配給每個進程一個唯一 ID,
這個 ID 叫做:PID ,進程還分為父進程和子進程,父進程(PPID)創建子進程(PID)。關系如下圖:

可以通過ps命令來查看進程的信息:每天一個linux命令(41):ps命令

需要注意:

  • 子進程一定要關閉
  • 子進程關閉一定要通知父進程,否則會出現‘僵屍進程’
  • 一定要先結束父進程,再結束子進程,否則會出現‘孤兒進程’

僵屍進程:一個進程使用fork創建子進程,如果子進程退出,而父進程並沒有調用wait或waitpid獲取子進程的狀態信息,那么子進程的進程描述符仍然保存在系統中。這種進程稱之為僵屍進程。(系統所能使用的進程號是有限制的,如果大量的產生僵死進程,將因為沒有可用的進程號而導致系統不能產生新的進程。則會拋出OSError: [Errno 35] Resource temporarily unavailable異常)

孤兒進程:一個父進程退出,而它的一個或多個子進程還在運行,那么那些子進程將成為孤兒進程。孤兒進程將被init進程(進程號為1)所收養,並由init進程對它們完成狀態收集工作。(沒有危害)

2.1.3 文件描述符

在UNIX中一切都是一個文件,當操作系統打開一存在的個文件的時候,便會返回一個‘文件描述符’,進程通過
操作該文件操作符,從而實現對文件的讀寫。Socket 是一個操作文件描述符的進程,Python 的 socket
模塊提供了這些操作系統底層的實現。我們只需要調用socket對象的方式就可以了。

需要注意

  • 文件描述符的回收機制是采用引用計數方式
  • 每次操作完文件描述符需要調用close()方法,關閉文件描述符。道理和進程一樣,操作系統都會最多可創建的文本描述符的限制,如果一直不關閉文本描述符的話,導致數量太多無法創建新的,就會拋出OSError: [Errno 24] Too many open file異常。

2.1.4 如何查看進程和用戶資源極限

計算機的計算和存儲能力都是有限的,統稱為計算機資源。

上面說了進程和文件描述符號都是有個最大數量(極限),下面就是用於查看和修改用戶資源限制的命令——ulimit

-a	列出所有當前資源極限。
-c	以 512 字節塊為單位,指定核心轉儲的大小。
-d	以 K 字節為單位指定數據區域的大小。
-f	使用 Limit 參數時設定文件大小極限(以塊為單位),或者在未指定參數時報告文件大小極限。缺省值為 -f 標志。
-H	指定設置某個給定資源的硬極限。如果用戶擁有 root 用戶權限,可以增大硬極限。任何用戶均可減少硬極限。
-m	以 K 字節為單位指定物理內存的大小(駐留集合大小)。系統未強制實施此限制。
-n	指定一個進程可以擁有的文件描述符數的極限。
-r	指定對進程擁有線程數的限制。
-s	以 K 字節為單位指定堆棧的大小。
-S	指定為給定的資源設置軟極限。軟極限可增大到硬極限的值。如果 -H 和 -S 標志均未指定,極限適用於以上二者。
-t	指定每個進程所使用的秒數。
-u	指定對用戶可以創建的進程數的限制。

常用命令如下:

  • ulimit -a:查看
  • ulimit -n:設置一個進程可擁有文件描述符數量
  • ulimit -u:最多可以創建多少個進程

2.2 Fork 方式的非阻塞 Server

采用 fork 的方式實現非阻塞 Server,主要原理就是當 socket 接受到(accept)一個請求,就 fork 出一個子進程
去處理這個請求。然后父進程繼續接受請求。從而實現並發的處理請求,不需要處理上一個請求才能接受、處理下一個請求。

import errno
import os
import signal
import socket

SERVER_ADDRESS = (HOST, PORT) = '', 8888
REQUEST_QUEUE_SIZE = 1024


def grim_reaper(signum, frame):
    while True:
        try:
            pid, status = os.waitpid(
                -1,          # Wait for any child process
                 os.WNOHANG  # Do not block and return EWOULDBLOCK error
            )
        except OSError:
            return

        if pid == 0:  # no more zombies
            return


def handle_request(client_connection):
    request = client_connection.recv(1024)
    print(request.decode())
    http_response = b"""\
HTTP/1.1 200 OK

Hello, World!
"""
    client_connection.sendall(http_response)


def serve_forever():
    listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    listen_socket.bind(SERVER_ADDRESS)
    listen_socket.listen(REQUEST_QUEUE_SIZE)
    print('Serving HTTP on port {port} ...'.format(port=PORT))

    signal.signal(signal.SIGCHLD, grim_reaper)

    while True:
        try:
            client_connection, client_address = listen_socket.accept()
        except IOError as e:
            code, msg = e.args
            # restart 'accept' if it was interrupted
            if code == errno.EINTR:
                continue
            else:
                raise

        pid = os.fork()
        if pid == 0:  # child
            listen_socket.close()  # close child copy
            handle_request(client_connection)
            client_connection.close()
            os._exit(0)
        else:  # parent
            client_connection.close()  # close parent copy and loop over

if __name__ == '__main__':
    serve_forever()

如閱讀代碼時出現的問題,可以參考下面的關鍵字:

  1. Python os.fork,文件句柄(引用計數)、子進程(pid==0)
  2. linux ulimt命令
  3. 僵屍進程如何避免僵屍進程,采用os.wait
  4. python signal模塊
  5. error.EINTR(慢系統調用:可能永遠阻塞的系統調用,例如:socket)
  6. 因為過多的子進程並發開始,同時結束,會並發的發出結束的信號,父進程的 signal 一瞬間接收過多的信號,導致了有的信號丟失,這種情況還是會遺留一些僵屍進程。這個時候就需要寫一個handle信號的方法。采用waitpidos.WHOHANG選項,進行死循環。以確保獲取到所有 signal
  7. OSError 因為waitpidos.WNOHANG選項,不會阻塞,但是如果沒有子進程退出,會拋出OSError,需要 catch 到這個異常,保證父進程接收到了每個子進程的結束信息,從而保證沒有僵屍進程。
waitpid()函數的options選項:

os.WNOHANG - 如果沒有子進程退出,則不阻塞waitpid()調用

os.WCONTINUED - 如果子進程從stop狀態變為繼續執行,則返回進程自前一次報告以來的信息。

os.WUNTRACED - 如果子進程被停止過而且其狀態信息還沒有報告過,則報告子進程的信息。

最后

該非阻塞 Server 是通過操作系統級別的 fork 實現的,用到了多進程和信號機制。

因為多進程解決非阻塞的問題,很好理解,但是十分消耗計算機資源的,后面會介紹更加輕量級的——利用事件循環實現非阻塞 Server。

挖個坑~

參考


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM