【从零开始】用 Python 搭建一个 Web 服务器 - Part 3(并发)(上)

我在 GitHub 上开源了这个系列文章的翻译版(还在跟进原作者的更新)欢迎 PR。
📖 📖 📖

原文:《Let’s Build A Web Server. Part 3.


“We learn most when we have to invent” —Piaget

Part 2 中,我们创建了一个可以处理基本 HTTP GET 请求的简约 WSGI 服务器。现在有一个问题:如何才能让我们的服务器一次处理多个请求(并发)?”,在本文中会给出答案。因此,抓紧扶好,老司机带你飞。真的是老司机带你飞的感觉哦。文章中的所有源代码都可以在 GitHub 上找到。

首先让我们回顾一个非常基本的 Web 服务器是什么样的,以及服务器需要做些什么来服务客户端的请求。我们在 Part 1Part 2 中创建的服务器是一个 迭代服务器,一次处理一个客户端请求。在完成处理当前客户端请求之前,它不能接受新的客户端连接。有些客户可能对它不满意,因为他们必须排队等候,对于繁忙的服务器,这个等待队列可能会很长。
lsbaws_part3_it1
下面是迭代服务器 webserver3a.py 的代码:

#####################################################################
# Iterative server - webserver3a.py                                 #
#                                                                   #
# Tested with Python 3.7.4                                          #
#####################################################################
import socket

SERVER_ADDRESS = (HOST, PORT) = '127.0.0.1', 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()

想要观察这个服务器一次只处理一个客户端请求的情况,可以稍微修改服务器代码,在它向客户端发送响应后添加 60 秒的延迟(可以当作处理一个请求要花60秒钟)。只需更改一行代码告诉服务器进程休眠 60 秒。
lsbaws_part3_it2
这是睡眠服务器 webserver3b.py 的代码:

#####################################################################
# Iterative server - web_server3b.py                                #
#                                                                   #
# Tested with Python 3.7.4                                          #
#                                                                   #
# 给客户端发送了一个相应之后,服务器 sleep 20 秒钟                     #
# 验证服务器一次只能处理一个客户端的请求,其它客户端只能等待             #
#####################################################################
import socket
import time

SERVER_ADDRESS = (HOST, PORT) = '127.0.0.1', 8888
REQUEST_QUEUE_SIZE = 5  # 请求队列大小增至5个,即可以有五个客户端在等在服务器


def handle_request(client_conn):
    request = client_conn.recv(1024)
    print(request.decode())
    # 网络编程中,服务器和客户端只认 bytes 类型
    http_response = b"""
HTTP/1.1 200 OK

Hello World !
"""
    client_conn.sendall(http_response)
    time.sleep(20)  # 休眠并阻塞进程 20 秒


def server_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(f"Serving HTTP on port: {PORT} ...")

    while True:
        client_conn, client_addr = listen_socket.accept()
        handle_request(client_conn)
        client_conn.close()


if __name__ == '__main__':
    server_forever()

通过以下命令行启动服务器:

$ python webserver3b.py

现在打开一个新的命令行终端然后运行下面的 curl 命令。你马上就能看到打印在屏幕上的 “Hello World!”

$ curl http://localhost:8888/hello
Hello, World!

趁热打铁再打开一个命令行终端,同样输入 curl 命令:

$ curl http://localhost:8888/hello

如果你在 20 秒内完事,那这个命令行会挂在那里不会产生任何输出。运行在第一个终端中的服务器也没有打印新的请求主体,在原作者的 Mac 上看起来就是这样(在右下角黄色框内的窗口能看到第二条 curl 命令在挂着,等待服务器接受连接):
lsbaws_part3_it3
在等待足够长时间之后(超过 20 秒),你应该会看到第二个终端的 curl 命令也打印出了 “Hello World !”
lsbaws_part3_it4
原因是服务器处理完第一个请求后才能再处理下一个请求,在此之前其他客户端的请求都在排队等待。

让我们稍微讨论一下客户端和服务器之间的通信方式。网络编程中,两个程序相互通信必须使用 sockets。在 Part 1Part 2 中我们都用到了 socket,但什么是 socket
lsbaws_part3_it_socket
socket(套接字) 是通信端点应用程序的抽象,在 Linux 系统中它是个文件描述符,两个通过 socket 编程的程序使用 socket 文件描述符进行通信。在本文中,我将专门讨论 Linux / Mac OS x 上的 TCP / IP 套接字。需要理解的一个重要概念是 TCP socket pair:TCP 套接字对。

TCP 连接中的 socket pair 套接字对是一个 4 元组,用于标识 TCP 连接的两个端点:本地 IP 地址、本地端口,目的 IP 地址和目的端口。

lsbaws_part3_it_socketpair
因此,元组 {10.10.10.2:49152,12.12.12.3:8888} 是唯一标识客户端上 TCP 连接的两个端点的套接字对,元组 {12.12.12.3:8888,10.10.10.2:49152} 是唯一标识服务器上 TCP 连接的两个相同端点的套接字对。在本例中,标识 TCP 连接的服务器端点的两个值,即 IP 地址 12.12.12.3 和端口 8888,被称为套接字(客户端端点的两个值同理)。

服务器创建套接字并开始接受客户端连接的标准顺序如下:
lsbaws_part3_it_server_socket_sequence

  1. 服务器通过下面的 Python 代码创建了一个 TCP / IP socket:

     listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    
  2. 服务器可以设置一些 socket 参数(这是可选参数,比如这句代码允许程序的进程重用同样的 IP 地址和端口,即服务器可以 bind 一个已经和客户端存在连接的地址,因为假如服务器主动关闭,客户端便会处于 TIME_WAIT 而不直接断开,此时立即重启服务器会报错 address already in used,设置 socket.SO_REUSEADDR 参数则可以避免)。

    listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    
  3. 然后,服务器绑定地址。bind 函数为套接字分配一个本地协议地址。使用 TCP 连接的话,调用 bind 允许我们同时指定端口号、IP地址,或两者都不指定。

    listen_socket.bind(SERVER_ADDRESS)
    
  4. 然后,服务器使该套接字成为侦听套接字。

    listen_socket.listen(REQUEST_QUEUE_SIZE)
    

listen 方法只由服务器调用。它告诉系统内核给这个 socket 传入连接请求。

侦听建立后,服务器开始在轮询中一次一个地接受客户端连接。当有可用的连接时,accept 方法返回已连接的客户端套接字。

client_conn, client_addr = listen_socket.accept()

然后,服务器从连接的客户端套接字读取请求,在其标准输出中打印数据,并向客户端发送请求的结果。

request = client_conn.recv(1024)
client_conn.sendall(http_response)

然后,服务器关闭客户端连接,准备接受新的客户端连接。

客户端通过 TCP / IP 与服务器通信所需执行的操作如下:
lsbaws_part3_it_client_socket_sequence
以下是客户端连接到服务器、发送请求和打印出接收到的响应的示例代码:

import socket

 # 创建一个 socket 并连接到服务器侦听请求的地址
 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
 sock.connect(('localhost', 8888))

 # 向服务器发送请求,得到服务器传回的响应
 sock.sendall(b'test')
 data = sock.recv(1024)
 print(data.decode())

创建套接字后,客户端需要连接到服务器。这是通过 connect 方法完成的:

sock.connect(('localhost', 8888))

客户端只需要提供服务器的 IP 地址或主机名以及要连接到的服务器的远程端口号即可。

你可能已经注意到客户端不调用 bindaccept。因为客户端不需要关心本地地址和本地端口号。当客户端调用 connect 时,内核中的 TCP / IP 堆栈自动分配本地 IP 地址和本地端口给客户端。本地端口被称为 ephemeral port 临时端口,即 short-lived port

服务器上标识客户端连接的知名服务的端口号称为 知名端口 well-known port(例如,80 表示 HTTP, 22 表示 SSH)。

启动你的 Python shell,建立一个客户端连接到你在本地主机上运行的服务器,看看内核给你创建的套接字分配了什么临时端口(在尝试下面的例子之前,先启动刚刚我们写的服务器 webserver3.pywebserver3.py):

>>> import socket
>>> sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
>>> sock.connect(('localhost', 8888))
>>> host, port = sock.getsockname()[:2]
>>> host, port
('127.0.0.1', 60589)

可以看到,系统内核将临时端口 60589 分配给了客户端套接字。

在回答 Part 2 的问题之前,我还需要快速介绍一些其他重要的概念。你很快就会明白为什么这很重要。这两个概念就是 进程文件描述符

什么是 进程进程 是指正在执行的程序的一个实例。例如,当服务器代码被执行时,它被加载到内存中,执行程序的一个实例称为进程。内核记录了一堆关于进程的信息——进程ID就是一个例子——用于内核跟踪一个进程。当我们运行一个迭代服务器 webserver3a.pywebserver3b.py 时,我们就运行了一个进程。
lsbaws_part3_it_server_process
在终端窗口中(我用Windows上的 git bash)启动服务器webserver3b.py

$ python webserver3b.py

在另一个终端窗口中,使用 ps 命令获取有关该进程的信息:

$ ps | grep python | grep -v grep
7182 ttys003    0:00.04 python webserver3b.py

ps 命令显示你实际上只运行了一个 Python 进程 webserver3b.py。当一个进程被创建时,内核会给它分配一个进程 ID:PID。在 UNIX 中,每个用户进程有一个父进程,而父进程又有自己的进程 ID,称为父进程 ID,简称 PPID。假设我们在默认情况下运行 BASH shell,当我们运行服务器时,将创建一个具有 PID 的新进程,其父 PID 被设置为 BASH shell 的 PID。
lsbaws_part3_it_process_myshell1
lsbaws_part3_it_ppid_pid
再次启动 Python shell,这将创建一个新进程,然后使用 os.getpid()os.getppid() 系统调用获得 Python shell 进程的 PID 和 PPID(你的 BASH shell 的 PID)。然后,在另一个终端窗口中运行 ps 命令,可以看到同样的 PPID(父进程 ID,在我的例子中是 3148 ) 和 PID。
lsbaws_part3_it_pid_ppid_screenshot

另一个需要知道的重要概念是 文件描述符。什么是文件描述符?文件描述符是一个非负整数,系统内核在打开现有文件、创建新文件或创建新套接字时会返回一个文件描述符给进程。你可能听说过,在 UNIX 中一切皆是文件。内核通过文件描述符指向进程引用的文件。当我们需要读取或写入一个文件时,可以使用文件描述符来标识它。

Python 提供了处理文件(和套接字)的高级 API,我们不必直接使用文件描述符来标识文件,但在底层,UNIX 中就是这样通过这些整数文件描述符来标识文件和套接字的:
lsbaws_part3_it_process_descriptors
默认情况下,UNIX shell 将文件描述符 0 分配给进程的标准输入(stdin),将文件描述符 1 分配给进程的标准输出(stdout),将文件描述符 2 分配给标准错误(stderr)。
lsbaws_part3_it_default_descriptors
尽管我前面提到 Python 提供了一个高级文件或类似文件的对象来处理文件描述符,但我们依然可以在对象上使用 fileno() 方法来获取与文件关联的文件描述符。回到 Python shell,看看如何做到这一点:

>>> import sys
>>> sys.stdin
<open file '<stdin>', mode 'r' at 0x102beb0c0>
>>> sys.stdin.fileno()
0
>>> sys.stdout.fileno()
1
>>> sys.stderr.fileno()
2

在 Python 中使用文件和套接字时,通常会使用高级文件或套接字对象,但有时也可能需要直接使用文件描述符。下面是一个示例,说明如何使用将文件描述符作为参数的 write 系统调用将字符串写入标准输出:

>>> import sys
>>> import os
>>> res = os.write(sys.stdout.fileno(), 'hello\n')
hello

这里有一个有趣的部分——这对你来说应该不再奇怪了,因为你已经知道在 Unix 中所有的东西都是一个文件——你的套接字也有一个与之相关联的文件描述符。同样,当在 Python 中创建一个 socket 时,你将获得一个对象而不是一个非负整数,但是你始终可以使用前面提到的 fileno() 方法直接访问 socket 的文件描述符。

>>> import socket
>>> sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
>>> sock.fileno()
3

我还想提一件事:你是否注意到在迭代服务器 webserver3b.py 的示例中,当服务器进程休眠 60 秒时,我们为何还可以使用第二个 curl 命令连接到服务器?即使 curl 没有立即输出任何东西,它只是挂在那里,但为什么服务器当时不接受连接,客户端也没有立即被拒绝,而是能够等待并最终连接到服务器呢?答案是套接字对象的 listen 方法及其 BACKLOG 参数,我在代码中给这个参数赋值了 REQUEST_QUEUE_SIZEBACKLOG 参数决定了内核中用于传入连接请求的队列的大小。当服务器 webserver3b.py 处于睡眠状态时,第二个 curl 命令会在请求队列中处于就绪状态,因为内核在传入的连接请求队列中有足够的空间用于服务器套接。

虽然增加 BACKLOG 参数并不能将我们的服务器转变为一次可以处理多个客户端请求的服务器,对于繁忙的服务器,有一个相当大的 BACKLOG 参数是很重要的,这样 accept 方法就不必等待建立新的连接,而是可以立即从等待队列中获取新连接并立即开始处理客户机请求。

Whoo-hoo!坚持到这里挺不容易的。让我们快速回顾一下到目前为止我们所学到的知识(或者如果对你来说都是基础知识的话,那么你就会温故而知新):
lsbaws_part3_checkpoint

  • 迭代服务器
  • 创建服务器 socket 的流程(socketbindlistenaccept
  • 创建客户端 socket 的流程(socketconnect
  • socket pair(套接字对)
  • socket
  • Ephemeral port and well-known port 临时端口和知名端口
  • 进程
  • 进程 ID(PID)、父进程 ID(PPID)、父子进程关系
  • 文件描述符
  • socket 中 listen 方法的 BACKLOG 参数的意义

现在我准备回答 Part 2 中的问题了:如何使服务器一次处理多个请求?或者换句话说,如何编写并发服务器?
lsbaws_part3_conc2_service_clients
在 Unix 下编写并发服务器的最简单方法是使用 fork() 系统调用。
lsbaws_part3_fork
由于篇幅太长,这里空白不够,我将在 Part 3 的下一篇继续探讨~

UPDATE: Sat, July 13, 2019

  • Updated the server code to run under Python 3.7+
  • Added resources used in preparation for the article

此系列的所有文章(已翻译):

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值