一起写一个Web服务器(3),web服务器,未经许可,禁止转载!英文


本文由 编橙之家 - 高世界 翻译,黄利民 校稿。未经许可,禁止转载!
英文出处:ruslan spivak。欢迎加入翻译组。
  • 一起写一个 Web 服务器(2)
  • 一起写一个 Web 服务器(1)

“发明创造时,我们学得最多” —— Piaget

在本系列第二部分,你已经创造了一个可以处理基本的 HTTP GET 请求的 WSGI 服务器。我还问了你一个问题,“怎么让服务器在同一时间处理多个请求?”在本文中你将找到答案。那么,系好安全带加大马力。你马上就乘上快车啦。准备好Linux、Mac OS X(或任何类unix系统)和 Python。本文的所有源码都能在GitHub上找到。

首先咱们回忆下一个基本的Web服务器长什么样,要处理客户端请求它得做什么。你在第一部分和第二部分创建的是一个迭代的服务器,每次处理一个客户端请求。除非已经处理了当前的客户端请求,否则它不能接受新的连接。有些客户端对此就不开心了,因为它们必须要排队等待,而且如果服务器繁忙的话,这个队伍会很长。

以下是迭代服务器webserver3a.py的代码:

Python
#####################################################################
# Iterative server - webserver3a.py                                 #
#                                                                   #
# Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X  #
#####################################################################
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()

要观察服务器同一时间只处理一个客户端请求,稍微修改一下服务器,在每次发送给客户端响应后添加一个60秒的延迟。添加这行代码就是告诉服务器睡眠60秒。

以下是睡眠版的服务器webserver3b.py代码:

Python
#########################################################################
# Iterative server - webserver3b.py                                     #
#                                                                       #
# Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X      #
#                                                                       #
# - Server sleeps for 60 seconds after sending a response to a client   #
#########################################################################
import socket
import time

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)
    time.sleep(60)  # sleep and block the process for 60 seconds

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

启动服务器:

Python
$ python webserver3b.py

现在打开一个新的控制台窗口,运行以下curl命令。你应该立即就会看到屏幕上打印出了“Hello, World!”字符串:

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

And without delay open up a second terminal window and run the same curl command:

立刻再打开一个控制台窗口,然后运行相同的curl命令:

Python
$ curl http://localhost:8888/hello

如果你是在60秒内做的,那么第二个curl应该不会立刻产生任何输出,而是挂起。而且服务器也不会在标准输出打印出新请求体。在我的Mac上看起来像这样(在右下角的黄色高亮窗口表示第二个curl命令正挂起,等待服务器接受这个连接):

当你等待足够长时间(大于60秒)后,你会看到第一个curl终止了,第二个curl在屏幕上打印出“Hello, World!”,然后挂起60秒,然后再终止:

它是这么工作的,服务器完成处理第一个curl客户端请求,然后睡眠60秒后开始处理第二个请求。这些都是顺序地,或者迭代地,一步一步地,或者,在我们例子中是一次一个客户端请求地,发生。

咱们讨论点客户端和服务器的通信吧。为了让两个程序能够网络通信,它们必须使用socket。你在第一部分和第二部分已经见过socket了,但是,socket是什么呢?

socket就是通信终端的一种抽象,它允许你的程序使用文件描述符和别的程序通信。本文我将详细谈谈在Linux/Mac OS X上的TCP/IP socket。理解socket的一个重要的概念是TCP socket对。

TCP的socket对是一个4元组,标识着TCP连接的两个终端:本地IP地址、本地端口、远程IP地址、远程端口。一个socket对唯一地标识着网络上的TCP连接。标识着每个终端的两个值,IP地址和端口号,通常被称为socket。

所以,元组{10.10.10.2:49152, 12.12.12.3:8888}是客户端TCP连接的唯一标识着两个终端的socket对。元组{12.12.12.3:8888, 10.10.10.2:49152}是服务器TCP连接的唯一标识着两个终端的socket对。标识TCP连接中服务器终端的两个值,IP地址12.12.12.3和端口8888,在这里就是指socket(同样适用于客户端终端)。

服务器创建一个socket并开始接受客户端连接的标准流程经历通常如下:

  1. 服务器创建一个TCP/IP socket。在Python里使用下面的语句即可:
    Python
    listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  2. 服务器可能会设置一些socket选项(这是可选的,上面的代码就设置了,为了在杀死或重启服务器后,立马就能再次重用相同的地址)。
    Python
    listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  3. 然后,服务器绑定指定地址,bind函数分配一个本地地址给socket。在TCP中,调用bind可以指定一个端口号,一个IP地址,两者都,或者两者都不指定。
    Python
    listen_socket.bind(SERVER_ADDRESS)
  4. 然后,服务器让这个socket成为监听socket。
    Python
    listen_socket.listen(REQUEST_QUEUE_SIZE)

listen方法只会被服务器调用。它告诉内核它要接受这个socket上的到来的连接请求了。

做完这些后,服务器开始循环地一次接受一个客户端连接。当有连接到达时,aceept调用返回已连接的客户端socket。然后,服务器从这个socket读取请求数据,在标准输出上把数据打印出来,并回发一个消息给客户端。然后,服务器关闭客户端连接,准备好再次接受新的客户端连接。

下面是客户端使用TCP/IP和服务器通信要做的:

以下是客户端连接服务器,发送请求并打印响应的示例代码:

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

创建socket后,客户端需要连接服务器。这是通过connect调用做到的:

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

客户端仅需提供要连接的远程IP地址或主机名和远程端口号即可。

可能你注意到了,客户端不用调用bind和accept。客户端没必要调用bind,是因为客户端不关心本地IP地址和本地端口号。当客户端调用connect时内核的TCP/IP栈自动分配一个本地IP址地和本地端口。本地端口被称为暂时端口( ephemeral port),也就是,short-lived 端口。

服务器上标识着一个客户端连接的众所周知的服务的端口被称为well-known端口(举例来说,80就是HTTP,22就是SSH)。操起Python shell,创建个连接到本地服务器的客户端连接,看看内核分配给你创建的socket的暂时的端口是多少(在这之前启动webserver3a.py或webserver3b.py):

Python
>>> 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这个暂时端口。

在我开始回答第二部分提出的问题前,我需要快速讲一下几个重要的概念。你很快就知道为什么重要了。两个概念是进程和文件描述符。

什么是进程?进程就是一个正在运行的程序的实例。比如,当服务器代码执行时,它被加载进内存,运行起来的程序实例被称为进程。内核记录了进程的一堆信息用于跟踪,进程ID就是一个例子。当你运行服务器 webserver3a.py 或 webserver3b.py 时,你就在运行一个进程了。

在控制台窗口运行webserver3b.py:

Python
$ python webserver3b.py

在别的控制台窗口使用ps命令获取这个进程的信息:

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

ps命令表示你确实运行了一个Python进程webserver3b。进程创建时,内核分配给它一个进程ID,也就是 PID。在UNIX里,每个用户进程都有个父进程,父进程也有它自己的进程ID,叫做父进程ID,或者简称PPID。假设默认你是在BASH shell里运行的服务器,那新进程的父进程ID就是BASH shell的进程ID。

自己试试,看看它是怎么工作的。再启动Python shell,这将创建一个新进程,使用 os.getpid() 和 os.getppid() 系统调用获取Python shell进程的ID和父进程ID(BASH shell的PID)。然后,在另一个控制台窗口运行ps命令,使用grep查找PPID(父进程ID,我的是3148)。在下面的截图你可以看到在我的Mac OS X上,子Python shell进程和父BASH shell进程的关系:

另一个要了解的重要概念是文件描述符。那么什么是文件描述符呢?文件描述符是当打开一个存在的文件,创建一个文件,或者创建一个socket时,内核返回的非负整数。你可能已经听过啦,在UNIX里一切皆文件。内核使用文件描述符来追踪进程打开的文件。当你需要读或写文件时,你就用文件描述符标识它好啦。Python给你包装成更高级别的对象来处理文件(和socket),你不必直接使用文件描述符来标识一个文件,但是,在底层,UNIX中是这样标识文件和socket的:通过它们的整数文件描述符。

默认情况下,UNIX shell分配文件描述符0给进程的标准输入,文件描述符1给进程的标准输出,文件描述符2给标准错误。

就像我前面说的,虽然Python给了你更高级别的文件或者类文件的对象,你仍然可以使用对象的fileno()方法来获取对应的文件描述符。回到Python shell来看看怎么做:

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

虽然在Python中处理文件和socket,通常使用高级的文件/socket对象,但有时候你需要直接使用文件描述符。下面这个例子告诉你如何使用write系统调用写一个字符串到标准输出,write使用整数文件描述符做为参数:

Python
>>> import sys
>>> import os
>>> res = os.write(sys.stdout.fileno(), 'hellon')
hello

有趣的是——应该不会惊讶到你啦,因为你已经知道在UNIX里一切皆文件——socket也有一个分配给它的文件描述符。再说一遍,当你创建一个socket时,你得到的是一个对象而不是非负整数,但你也可以使用我前面提到的fileno()方法直接访问socket的文件描述符。

还有一件事我想说下:你注意到了吗?在第二个例子webserver3b.py中,当服务器进程在60秒的睡眠时你仍然可以用curl命令来连接。当然啦,curl没有立刻输出什么,它只是在那挂起。但为什么服务器不接受连接,客户端也不立刻被拒绝,而是能连接服务器呢?答案就是socket对象的listen方法和它的BACKLOG参数,我称它为 REQUEST_QUEUE_SIZE(请求队列长度)。BACKLOG参数决定了内核为进入的连接请求准备的队列长度。当服务器webser3b.py睡眠时,第二个curl命令可以连接到服务器,因为内核在服务器socket的进入连接请求队列上有足够的可用空间。

然而增加BACKLOG参数不会神奇地让服务器同时处理多个客户端请求,设置一个合理大点的backlog参数挺重要的,这样accept调用就不用等新连接建立起来,立刻就能从队列里获取新的连接,然后开始处理客户端请求啦。

吼吼!你已经了解了非常多的背景知识啦。咱们快速简要重述到目前为止你都学了什么(如果你都知道啦就温习一下吧)。

  • 迭代服务器
  • 服务器socket创建流程(socket, bind, listen, accept)
  • 客户端连接创建流程(socket, connect)
  • socket对
  • socket
  • 临时端口和众所周知端口
  • 进程
  • 进程ID(PID),父进程ID(PPID),父子关系。
  • 文件描述符
  • listen方法的BACKLOG参数的意义

现在我准备回答第二部分问题的答案了:“怎样才能让服务器同时处理多个请求?”或者换句话说,“怎样写一个并发服务器?”

在Unix上写一个并发服务器最简单的方法是使用fork()系统调用。

下面就是新的牛逼闪闪的并发服务器webserver3c.py的代码,它能同时处理多个客户端请求(和咱们迭代服务器例子webserver3b.py一样,每个子进程睡眠60秒):

Python
###########################################################################
# Concurrent server - webserver3c.py                                      #
#                                                                         #
# Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X        #
#                                                                         #
# - Child process sleeps for 60 seconds after handling a client's request #
# - Parent and child processes close duplicate descriptors                #
#                                                                         #
###########################################################################
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()

在深入讨论for如何工作之前,先自己试试,看看服务器确实可以同时处理多个请求,不像webserver3a.py和webserver3b.py。用下面命令启动服务器:

Python
$ python webserver3c.py

像你以前那样试试用两个curl命令,自己看看,现在虽然服务器子进程在处理客户端请求时睡眠60秒,但不影响别的客户端,因为它们是被不同的完全独立的进程处理的。你应该能看到curl命令立刻就输出了“Hello, World!”,然后挂起60秒。你可以接着想运行多少curl命令就运行多少(嗯,几乎是任意多),它们都会立刻输出服务器的响应“Hello, Wrold”,而且不会有明显的延迟。试试看。

理解fork()的最重要的点是,你fork了一次,但它返回了两次:一个是在父进程里,一个是在子进程里。当你fork了一个新进程,子进程返回的进程ID是0。父进程里fork返回的是子进程的PID。

我仍然记得当我第一次知道它使用它时我对fork是有多着迷。它就像魔法一样。我正读着一段连续的代码,然后“duang”的一声:代码克隆了自己,然后就有两个相同代码的实例同时运行。我想除了魔法无法做到,我是认真哒。

当父进程fork了一个新的子进程,子进程就获取了父进程文件描述符的拷贝:

你可能已经注意到啦,上面代码里的父进程关闭了客户端连接:

Python
else:  # parent
    client_connection.close()  # close parent copy and loop over

那么,如果它的父进程关闭了同一个socket,子进程为什么还能从客户端socket读取数据呢?答案就在上图。内核使用描述符引用计数来决定是否关闭socket。只有当描述符引用计数为0时才关闭socket。当服务器创建一个子进程,子进程获取了父进程的文件描述符拷贝,内核增加了这些描述符的引用计数。在一个父进程和一个子进程的场景中,客户端socket的描述符引用计数就成了2,当父进程关闭了客户端连接socket,它仅仅把引用计数减为1,不会引发内核关闭这个socket。子进程也把父进程的listen_socket拷贝给关闭了,因为子进程不用管接受新连接,它只关心处理已经连接的客户端的请求:

Python
listen_socket.close()  # close child copy

本文后面我会讲下如果不关闭复制的描述符会发生什么。

你从并发服务器源码看到啦,现在服务器父进程唯一的角色就是接受一个新的客户端连接,fork一个新的子进程来处理客户端请求,然后重复接受另一个客户端连接,就没有别的事做啦。服务器父进程不处理客户端请求——它的小弟(子进程)干这事。

跑个题,我们说两个事件并发到底是什么意思呢?

当我们说两个事件并发时,我们通常表达的是它们同时发生。简单来说,这也不错,但你要知道严格定义是这样的:

Python
如果你不能通过观察程序来知道哪个先发生的,那么这两个事件就是并发的。

又到了简要重述目前为止已经学习的知识点和概念的时间啦.

  • 在Unix下写一个并发服务器最简单的方法是使用fork()系统调用
  • 当一个进程fork了一个新进程时,它就变成了那个新fork产生的子进程的父进程。
  • 在调用fork后,父进程和子进程共享相同的文件描述符。
  • 内核使用描述符引用计数来决定是否关闭文件/socket。
  • 服务器父进程的角色是:现在它干的所有活就是接受一个新连接,fork一个子进来来处理这个请求,然后循环接受新连接。

咱们来看看,如果在父进程和子进程中你不关闭复制的socket描述符会发生什么吧。以下是个修改后的版本,服务器不关闭复制的描述符,webserver3d.py:

Python
###########################################################################
# Concurrent server - webserver3d.py                                      #
#                                                                         #
# Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X        #
###########################################################################
import os
import socket

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

def handle_request(client_connection):
    request = client_connection.recv(1024)
    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))

    clients = []
    while True:
        client_connection, client_address = listen_socket.accept()
        # store the reference otherwise it's garbage collected
        # on the next loop run
        clients.append(client_connection)
        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()
            print(len(clients))

if __name__ == '__main__':
    serve_forever()

启动服务器:

Python
$ python webserver3d.py

使用curl去连接服务器:

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

好的,curl打印出来并发服务器的响应,但是它不终止,一直挂起。发生了什么?服务器不再睡眠60秒了:它的子进程开心地处理了客户端请求,关闭了客户端连接然后退出啦,但是客户端curl仍然不终止。

那么,为什么curl不终止呢?原因就在于复制的文件描述符。当子进程关闭了客户端连接,内核减少引用计数,值变成了1。服务器子进程退出,但是客户端socket没有被内核关闭掉,因为引用计数不是0啊,所以,结果就是,终止数据包(在TCP/IP说法中叫做FIN)没有发送给客户端,所以客户端就保持在线啦。这里还有个问题,如果服务器不关闭复制的文件描述符然后长时间运行,最终会耗尽可用文件描述符。

使用Control-C停止webserver3d.py,使用shell内建的命令ulimit检查一下shell默认设置的进程可用资源:

Python
$ ulimit -a
core file size          (blocks, -c) 0
data seg size           (kbytes, -d) unlimited
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 3842
max locked memory       (kbytes, -l) 64
max memory size         (kbytes, -m) unlimited
open files                      (-n) 1024
pipe size            (512 bytes, -p) 8
POSIX message queues     (bytes, -q) 819200
real-time priority              (-r) 0
stack size              (kbytes, -s) 8192
cpu time               (seconds, -t) unlimited
max user processes              (-u) 3842
virtual memory          (kbytes, -v) unlimited
file locks                      (-x) unlimited

看到上面的了咩,我的Ubuntu上,进程的最大可打开文件描述符是1024。

现在咱们看看怎么让服务器耗尽可用文件描述符。在已存在或新的控制台窗口,调用服务器最大可打开文件描述符为256:

Python
$ ulimit -n 256

在同一个控制台上启动webserver3d.py:

Python
$ python webserver3d.py

使用下面的client3.py客户端来测试服务器。

Python
#####################################################################
# Test client - client3.py                                          #
#                                                                   #
# Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X  #
#####################################################################
import argparse
import errno
import os
import socket

SERVER_ADDRESS = 'localhost', 8888
REQUEST = b"""
GET /hello HTTP/1.1
Host: localhost:8888

"""

def main(max_clients, max_conns):
    socks = []
    for client_num in range(max_clients):
        pid = os.fork()
        if pid == 0:
            for connection_num in range(max_conns):
                sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
                sock.connect(SERVER_ADDRESS)
                sock.sendall(REQUEST)
                socks.append(sock)
                print(connection_num)
                os._exit(0)

if __name__ == '__main__':
    parser = argparse.ArgumentParser(
        description='Test client for LSBAWS.',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
    )
    parser.add_argument(
        '--max-conns',
        type=int,
        default=1024,
        help='Maximum number of connections per client.'
    )
    parser.add_argument(
        '--max-clients',
        type=int,
        default=1,
        help='Maximum number of clients.'
    )
    args = parser.parse_args()
    main(args.max_clients, args.max_conns)

在新的控制台窗口里,启动client3.py,让它创建300个连接同时连接服务器。

Python
$ python client3.py --max-clients=300

很快服务器就崩了。下面是我电脑上抛异常的截图:

教训非常明显啦——服务器应该关闭复制的描述符。但即使关闭了复制的描述符,你还没有接触到底层,因为你的服务器还有个问题,僵尸!

是哒,服务器代码就是产生了僵尸。咱们看下是怎么产生的。再次运行服务器:

Python
$ python webserver3d.py

在另一个控制台窗口运行下面的curl命令:

Python
$ curl http://localhost:8888/hello

现在运行ps命令,显示运行着的Python进程。以下是我的Ubuntu电脑上的ps输出:

Python
$ ps auxw | grep -i python | grep -v grep
vagrant   9099  0.0  1.2  31804  6256 pts/0    S+   16:33   0:00 python webserver3d.py
vagrant   9102  0.0  0.0      0     0 pts/0    Z+   16:33   0:00 [python] &lt;defunct&gt;

你看到上面第二行了咩?它说PId为9102的进程的状态是Z+,进程的名称是。这个就是僵尸啦。僵尸的问题在于,你杀死不了他们啊。

即使你试着用 $ kill -9 来杀死僵尸,它们还是会幸存下来哒,自己试试看看。

僵尸到底是什么呢?为什么咱们的服务器会产生它们呢?僵尸就是一个进程终止了,但是它的父进程没有等它,还没有接收到它的终止状态。当一个子进程比父进程先终止,内核把子进程转成僵尸,存储进程的一些信息,等着它的父进程以后获取。存储的信息通常就是进程ID,进程终止状态,进程使用的资源。嗯,僵尸还是有用的,但如果服务器不好好处理这些僵尸,系统就会越来越堵塞。咱们看看怎么做到的。首先停止服务器,然后新开一个控制台窗口,使用ulimit命令设置最大用户进程为400(确保设置打开文件更高,比如500吧):

Python
$ ulimit -u 400
$ ulimit -n 500

在同一个控制台窗口运行webserver3d.py:

Python
$ python webserver3d.py

新开一个控制台窗口,启动client3.py,让它创建500个连接同时连接到服务器:

Python
$ python client3.py --max-clients=500

然后,服务器又一次崩了,是OSError的错误:抛了资源临时不可用的异常,当试图创建新的子进程时但创建不了时,因为达到了最大子进程数限制。以下是我的电脑的截图:

看到了吧,如果你不处理好僵尸,服务器长时间运行就会出问题。我会简短讨论下服务器应该怎样处理僵尸问题。

咱们简要重述下目前为止你已经学习到主要知识点:

  • 如果不关闭复制描述符,客户端不会终止,因为客户端连接不会关闭。
  • 如果不关闭复制描述符,长时间运行的服务器最终会耗尽可用文件描述符(最大打开文件)。
  • 当fork了一个子进程,然后子进程退出了,父进程没有等它,而且没有收集它的终止状态,它就变成僵尸了。
  • 僵尸要吃东西,我们的场景中,就是内存。服务器最终会耗尽可用进程(最大用户进程),如果不处理好僵尸的话。
  • 僵尸杀不死的,你需要等它们。

那么,处理好僵尸的话,要做什么呢?要修改服务器代码去等僵尸,获取它们的终止状态。通过调用wait系统调用就好啦。不幸的是,这不完美,因为如果调用wait,然而没有终止的子进程,wait就会阻塞服务器,实际上就是阻止了服务器处理新的客户端连接请求。有其他办法吗?当然有啦,其中之一就是使用信息处理器和wait系统调用组合。

以下是如何工作的。当一个子进程终止了,内核发送SIGCHLD信号。父进程可以设置一个信号处理器来异步地被通知,然后就能wait子进程获取它的终止状态,因此阻止了僵尸进程出现。

顺便说下,异步事件意味着父进程不会提前知道事件发生的时间。

修改服务器代码,设置一个SIGCHLD事件处理器,然后在事件处理器里wait终止的子进程。webserver3e.py代码如下:

Python
###########################################################################
# Concurrent server - webserver3e.py                                      #
#                                                                         #
# Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X        #
###########################################################################
import os
import signal
import socket
import time

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

def grim_reaper(signum, frame):
    pid, status = os.wait()
    print(
        'Child {pid} terminated with status {status}'
        'n'.format(pid=pid, status=status)
    )

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)
    # sleep to allow the parent to loop over to 'accept' and block there
    time.sleep(3)

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:
        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)
        else:  # parent
            client_connection.close()

if __name__ == '__main__':
    serve_forever()

启动服务器:

Python
$ python webserver3e.py

使用老朋友curl给修改后的并发服务器发送请求:

Python
$ curl http://localhost:8888/hello

观察服务器:

刚才发生了什么?accept调用失败了,错误是EINTR。

当子进程退出,引发SIGCHLD事件时,父进程阻塞在accept调用,这激活了事件处理器,然后当事件处理器完成时,accept系统调用就中断了:

别着急,这个问题很好解决。你要做的就是重新调用accept。以下是修改后的代码:

Python
###########################################################################
# Concurrent server - webserver3f.py                                      #
#                                                                         #
# Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X        #
###########################################################################
import errno
import os
import signal
import socket

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

def grim_reaper(signum, frame):
    pid, status = os.wait()

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

启动修改后的webserver3f.py:

Python
$ python webserver3f.py

使用curl给修改后的服务器发送请求:

Python
$ curl http://localhost:8888/hello

看到了吗?没有EINTR异常啦。现在,验证一下吧,没有僵尸了,带wait的SIGCHLD事件处理器也能处理好子进程了。怎么验证呢?只要运行ps命令,看看没有Z+状态的进程(没有进程)。太棒啦!没有僵尸在四周跳的感觉真安全呢!

  • 如果fork了子进程并不wait它,它就成僵尸了。
  • 使用SIGCHLD事件处理器来异步的wait终止了的子进程来获取它的终止状态
  • 使用事件处理器时,你要明白,系统调用会被中断的,你要做好准备对付这种情况

嗯,目前为止,一次都好。没有问题,对吧?好吧,几乎滑。再次跑下webserver3f.py,这次不用curl请求一次了,改用client3.py来创建128个并发连接:

Python
$ python client3.py --max-clients 128

现在再运行ps命令

Python
$ ps auxw | grep -i python | grep -v grep

看到了吧,少年,僵尸又回来了!

这次又出什么错了呢?当你运行128个并发客户端时,建立了128个连接,子进程处理了请求然后几乎同时终止了,这就引发了SIGCHLD信号洪水般的发给父进程。问题在于,信号没有排队,父进程错过了一些信号,导致了一些僵尸到处跑没人管:

解决方案就是设置一个SIGCHLD事件处理器,但不用wait了,改用waitpid系统调用,带上WNOHANG参数,循环处理,确保所有的终止的子进程都被处理掉。以下是修改后的webserver3g.py:

Python
###########################################################################
# Concurrent server - webserver3g.py                                      #
#                                                                         #
# Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X        #
###########################################################################
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()

启动服务器:

Python
$ python webserver3g.py

使用测试客户端client3.py:

Python
$ python client3.py --max-clients 128

现在验证一下没有僵尸了吧。哈!没有僵尸的日子真好!

 

恭喜!这真是段很长的旅程啊,希望你喜欢。现在你已经拥有了自己的简单并发服务器,而且这个代码有助于你在将来的工作中开发一个产品级的Web服务器。

我要把它留作练习,你来修改第二部分的WSGI服务器,让它达到并发。你在这里可以找到修改后的版本。但是你要自己实现后再看我的代码哟。你已经拥有了所有必要的信息,所以,去实现它吧!

接下来做什么呢?就像Josh Billings说的那样,

像邮票那样——用心做一件事,直到完成。

去打好基础吧。质疑你已经知道的,保持深入研究。

如果你只学方法,你就依赖方法。但如果你学会原理,你可以发明自己的方法。—— 爱默生

以下是我挑出来对本文最重要的几本书。它们会帮你拓宽加深我提到的知识。我强烈建议你想言设法弄到这些书:从朋友那借也好,从本地图书馆借,或者从亚马逊买也行。它们是守护者:

  1. Unix网络编程,卷1:socket网络API(第三版)
  2. UNIX环境高级编程,第三版
  3. Linux编程接口:Linux和UNIX系统编辑手册
  4. TCP/IP详解,卷1:协议(第二版)
  5. The Little Book of SEMAPHORES (2nd Edition): The Ins and Outs of Concurrency Control and Common Mistakes. Also available for free on the author’s site here.

评论关闭