自己寫一個web服務器(2)

  • 之前我們搭建了一個極簡的WSGI服務器, 可以處理基本的HTTP請求,但是由于我們建立的服務一次只能處理一個客戶端請求,在當前的請求處理完成之前,它不能接受新的連接。

服務器的代碼

  • 我們將處理請求的這塊邏輯抽出來,代碼如下:
import socket

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

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))

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

if __name__ == '__main__':
    serve_forever()

仔細看,當handle_request方法還沒有結束的時候,循環會阻塞在這里,無法監聽后續的請求。

客戶端與服務端之間的通信

  • 為了讓兩個程序通過網絡彼此通訊,我們用到了socket,那么socket是什么呢?

socket

  • socket是一個通信終端的抽象概念,它允許程序通過文件描述符與另一個程序通信
  • TCP連接的socket對是一個擁有4個值的tuple,用來標識TCP連接的兩個端點: 本地IP地址、本地端口、外部IP地址、外部端口。
  • socket對是網絡上每個tcp連接的唯一標識。這兩個成對的值標識各自端點,一個IP地址和一個端口號,通常被稱為一個socket。
  • 例子
    socket pair
  • tuple {10.10.10.2:49152, 12.12.12.3:8888} 是客戶端上一個唯一標識兩個TCP連接終端的socket, {12.12.12.3:8888, 10.10.10.2:49152} 是服務端上一個唯一標識相同的兩個TCP連接終端的socket。IP地址12.12.12.3和端口8888在TCP連接中用來識別服務器端點(同樣適用于客戶端)。

Python建立socket鏈接

  • 服務器創建一個TCP/IP socket鏈接

    • 新建socket連接
    # AF_INET:服務器之間網絡通信。  SOCK_STREAM: 流式socket , for TCP
    listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    
    • 設置一些socket選項(這是可選的)
    # SOL_SOCKET: 想要在套接字級別上設置選項,就必須把level設置為 SOL_SOCKET
    # SO_REUSEADDR: 打開或關閉地址復用功能。當值不等于0時,打開,否則,關閉。
    listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    
    • 服務器綁定地址
    # 在TCP中,調用bind允許你指定端口號,IP地址,要么兩個都有,要么就都沒有。
    listen_socket.bind(SERVER_ADDRESS)
    
    • 開始監聽
    # listen方法只供服務器調用。它告訴內核應該接受給這個socket傳入的連接請求
    # REQUEST_QUEUE_SIZE代表連接請求隊列的長度
    listen_socket.listen(REQUEST_QUEUE_SIZE)
    
    • 上述步驟完成后,服務器開始逐個接受客戶端連接。當一個連接可用accept返回要連接的客戶端socket。然后服務器讀從客戶端socket取請求數據,打印出響應標準輸出然后給客戶端socket傳回消息。然后服務器關閉客戶端連接,準備接受一個新的客戶端連接。
  • 客戶端連接服務器

    • 和服務端0建立連接類似
     import socket
    
     # create a socket and connect to a server
     sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
     sock.connect(('localhost', 8888))
    
     # send and receive some data
     sock.sendall(b'test')
     data = sock.recv(1024)
     print(data.decode())
    
    • 客戶端只需提供服務器的遠程地址或是主機名和遠程端口號來連接。
    • 客戶端沒有調用bind和accept。其原因是客戶端不關心本地IP地址和端口號。客戶端調用connect時內核中的TCP/IP socket會自動分配本地IP地址和端口號。本地端口被稱為臨時端口。

文件描述符

  • 當一個進程打開現有的文件,創建一個新的文件,或者創建一個新的socket連接的時候,內核返回給它的一個非負整數。
  • 在UNIX中,一切都是文件,內核通過文件描述符指向一個打開的文件。當你需要讀寫文件的時候,就是用文件描述符來識別的。
  • UNINS shell默認分配文件描述符0給標準輸入進程,1是標準輸出,2是標準錯誤

怎么保證你的服務器能同時處理多個請求?或者換個說法,如何編寫并發服務器?

  • 在UNIX下,最簡單的方式是用一個fork()系統調用
    import os
    import socket
    import time
    
    SERVER_ADDRESS = (HOST, PORT) = '', 8888
    REQUEST_QUEUE_SIZE = 5
    
    
    def handle_request(client_connection):
        request = client_connection.recv(1024)
        print(
            'Child PID: {pid}. Parent PID {ppid}'.format(
                pid=os.getpid(),
                ppid=os.getppid(),
            )
        )
        print(request.decode())
        http_response = b"""\
    HTTP/1.1 200 OK
    Hello, World!
    """
        client_connection.sendall(http_response)
        time.sleep(60)
    
    
    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))
        print('Parent PID (PPID): {pid}\n'.format(pid=os.getpid()))
    
        while True:
            client_connection, client_address = listen_socket.accept()
            pid = os.fork()
            if pid == 0:  # child
                listen_socket.close()  # close child copy
                handle_request(client_connection)
                client_connection.close()
                os._exit(0)  # child exits here
            else:  # parent
                client_connection.close()  # close parent copy and loop over
    
    if __name__ == '__main__':
        serve_forever()
    
  • 運行多個curl命令之后,即使子進程處理一個進程之后會休眠60秒,但是這并不會影響處理其它的客戶端請求,因為它們是完全獨立的進程了。
  • 在調用一次fork()之后,新進程會是原進程的子進程,原進程稱為父進程。 子進程會復制父進程的數據信息。而后程序就分兩個進程繼續運行了。在子進程內,這個方法會返回0;在父進程內,這個方法會返回子進程的編號PID??梢允褂肞ID來區分兩個進程。
  • 在上面的代碼中,父進程關閉了客戶端的連接,那么子進程是怎么繼續讀取客戶端的socket連接的呢?
    • 父進程fork出一個子進程之后,這個子進程得到了一個父進程文件描述符。
    • 內核根據文件描述符的值來決定是否關閉連接socket,只有其值為0才會關閉。
    • 服務器產生一個子進程,子進程拷貝父進程文件描述符,內核增加引用描述符的值。在一個父進程一個子進程的例子中,描述符引用值就是2,當父進程關閉連接socket,它只會把引用值減為1,不會小到讓內核關閉socket。
    • 子進程也關閉了父進程監聽socket的重復拷貝,是因為它不關心接受新的客戶端連接,而只在乎處理已連接客戶端的響應:
      listen_socket.close() # close child copy

小結

  • 服務器socket創建過程(socket,bind,listen,accept)
  • 客戶端socket創建過程(socket,connect)
  • fork()函數的意義:創建子進程,復制父進程的數據信息。而后程序就分兩個進程繼續運行
  • 在UNIX下寫并發服務器最簡單的方法是用fork()系統調用。一個進程fork出一個新進程,它就變成新進程的父進程。
  • 調用fork后,父進程和子進程公用同樣的文件描述符。內核用文件描述符應用值來決定關閉或打開文件/socket。
  • 服務器父進程的角色:從客戶端接受新的連接,fork一個子進程去處理請求,繼續接受新的連接。
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容