【翻译】一步步开发打造一个Web服务器.Part 3.

原文链接:Let’s Build A Web Server. Part 3.
译文链接:【翻译】一步步开发打造一个Web服务器.Part 3.
本文代码基于python2.x

发现错误请在评论区指出,多谢

本系列其他文章:
【翻译】一步步开发一个Web服务器.Part 1.
【翻译】一步步开发一个Web服务器.Part 2.
【翻译】一步步开发一个Web服务器.Part 3.

我们要进行发明时,我们学的东西最多 —— 皮亚杰

Part2中,你已经创造了一个能处理基本HTTP GET方法请求的WSGI服务器。我还留了个课后作业,“怎样保证你的服务器能在同一时间处理多个请求?”我们将在本章中找寻答案。让我们策马扬鞭,即刻启程。准备好你的linux、Mac OS X(或者别的什么*nix操作系统)、Python。本文中所有代码都可以从Github上下载。

首先,我们回忆一下,一个基本的Web服务器应该有什么?为了处理客户端请求,服务器应该做什么?在文章Part1Part2中写的那个服务器是一个迭代服务器,一次只能处理一个客户端的请求。这时候有些客户端就不乐意了,因为他们要排队,或许是很长很长的队。

这里是迭代服务器的代码 webserver3a.py:

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

为了观察服务器是怎样一次只处理一个客户端的,我修改了服务器的一点儿代码,并在发送给客户端响应之后加了60s的延时。只是修改了一行,让服务器先睡会儿。

下面是那个睡懒觉的服务器webserver3b.py

#########################################################################
# 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 webserver3b.py

现在打开一个新的终端,使用curl命令。你应该直接能看到“Hello World!”输出出来。

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

如果没有延时,打开第二个终端,执行同样的curl命令:

$ curl http://localhost:8888/hello

如果你已经设置了60S延时,第二个curl应该不会立刻产生任何输出,只是在这儿等着。服务器也不会打印一个新的请求主体。下图是在我Mac上跑的结果。(右下角是第二个终端窗口,等着服务器的连接和接收请求)

等到超过60S后,你应该能看到第一个第一个终端和第二个终端输出的“Hello, World!”再等60S,会…:

服务器工作的方式是:服务器处理完第一个请求,只有等睡60S秒才能处理第二个请求。这一切都是依次顺序发生或者迭代发生,在我们例子中,一次只能一个客户端请求。

我们稍微讨论一下客户端和服务器之间的通信。为了让两个程序通过网络进行交流,他们必须使用套接字(sockets)。在Part1Part2中也提到过套接字。那么什么是套接字?

socket是通信端点的抽象,它允许你的程序许通过文件描述符去跟另一个程序交流。本文中只讨论Linux/Mac OS X上的TCP/IP套接字。要理解一个重要的概念就是TCP套接字对(TCP socket pair)。

TCP连接中的套接字对是个四元组,用以确认两个TCP端点的连接,包括:本地IP、本地端口、外部IP、外部端口。一个套接字对独一无二地表明了一个网络上地连接。IP和端口这两个值常被叫做socket,他们俩可以标明一个端点。——Unix 网络编程

所以,元组{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,这两个值被称为套接字(同样适用于客户端)。一个服务器创建套接字开始接受客户端连接的标准化流程如下:

1.服务器创建一个TCP/IP套接字。在python中可以用下面的语句实现。

listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

2.服务器或许设置了一些套接字选项(这些都是可选的,但是你能看到上面的服务器代码那么做,是为了能够在你关停重启服务器的时候能够重复使用相同的地址)

listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

3.然后,服务器绑定到这个地址。绑定函数为套接字分配了一个本地协议地址。使用TCP,调用绑定函数允许你可以指定一个端口、IP,或者两个都指定,或者两个都不。

listen_socket.bind(SERVER_ADDRESS)

4.接下来,服务器令套接字变为一个监听套接字

listen_socket.listen(REQUEST_QUEUE_SIZE)

监听函数(listen)只能被服务器调用,它告诉内核应该接受这个套接字的连接请求。

这些都完成了之后,服务器开始接收客户端连接,一次只接受一个,周而复始。当这有一个可接受的连接,接受方法(accept)返回一个已经连接客户端的套接字。然后,服务器从已连接的客户端套接字读取请求数据,打印出来并返回给客户端一个信息。接下来,服务器关闭与客户端的连接,并准备好下一次接受一个新连接。

下图是一个客户端通过TCP/IP与服务器交流是所需要做的:

下面的代码是有一个简单的例子,客户端连接到你的服务器,发送一个请求,输出响应。

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

创建套接字之后,客户端需要连接到服务器,这一动作使用connect函数完成:

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

客户端只需要提供远端服务器的IP/主机名和端口,就能连接。

或许你已经注意到了客户端不会调用bind和accept函数。客户端不需要调用bind因为客户端不关心本地IP和本地端口。当客户端需要调用connect函数时,内核里的TCP/IP栈会自动分配本地IP和本地端口。这个本地端口被称作临时端口(ephemeral port)。

有些服务器端口用户标明某些客户端连接的知名服务,因此被称为知名端口(well-known port)例如:80端口用于HTTP、22端口用于SSH。启动python shell,建立一个客户端与本地服务器连接,你会看到内核给你创建的socket分配一个什么样的端口(最开始要启动webserver3a.py或者webserver3b.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临时端口。

下面有一些很重要的概念,在我公布Part2答案之前,我需要你尽快弄懂。你很快就会见识到为什么这么重要了。两个概念:进程(process)和文件描述符(file descriptor)。

什么是进程?一个进程就是一个执行程序的实例化。举个例子,当服务器代码在执行时,他被加载进内存,并且实例化了一个执行程序,这就叫进程。内核记录了许多关于这个进程的信息,例如进程ID,可以方便管理进程。当你运行你的迭代服务器(webserver3a.py或者webserver3b.py)的时候,你开启了一个进程。

在终端开启服务器webserver3b.py

$ python webserver3b.py

并且在另一终端使用ps命令获取进程信息

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

ps命令向你展示了你确实只运行了webserver3b.py这一个python进程。当一个进程被创建,内核会分配给进程一个ID,叫PID。在UNIX中,每个用户进程都有一个父进程,同样也有自己的父进程ID叫PPID(parent process ID的简称)。我假定你运行的是bash shell,当你启动服务器时,一个新的进程被创建,并且指派一个PID,它的PPID设置的是这个bash shell的ID。

自己尝试去了解他们是怎么工作的。再次启动你的python shell,内核会创建一个新的进程,通过使用os.getpid()和os.getppid()系统函数来获取python shell的PID和PPID。然后在另一个终端,使用ps、grep命令抓取这个PPID进程。下面的截图中,你会看到子进程python shell和父进程bash shell之间的父子关系。

另一个重要的概念是文件描述符(file descriptor)。那么什么是文件描述符?当打开一个已存在的文件、创建一个新文件或者创建一个套接字的时候,系统返回给进程的一个非负整数。你或许已经听说过UNIX中万物皆文件。内核通过文件描述符来定位一个进程打开的文件。当你需要读写一个文件的时候,你需要用文件描述符来标识它。python给出了一个高层次的对象来处理文件或者套接字,你不必使用文件描述符来确认这个文件了。但是在底层实现中,这就是一个文件或者套接字在UNIX系统中怎样被确认的:通过整型的文件描述符。

默认情况下:UNIX shell把0号文件描述符分配给了标准输入,1号给了标准输出,2号给了标准错误。

就像我前面提到的,尽管python给你了一个更高层次的文件或者类文件对象使用,你仍然可以使用对象的fileno()函数来获取文件对应的文件描述符。现在回到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中使用文件或者套接字的时候,你通常使用高层次的文件/套接字对象,但有些时候你需要直接使用文件描述符。下面有一个例子,你可以通过调用一个write()系统函数将字符串直接写入到标准输出中,并且整型的文件描述符作为write()的一个参数。

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

这里还有一部分挺有意思,或许不会让你吃惊,你已经知道了UNIX中万物皆文件,套接字也有一个与之相关联的文件描述符。再次,当你在python中创建一个套接字的时候,你得到一个返回对象而不是一个非负整数,但你同样可以直接使用fileno()函数获取文件描述符。朋友们,知识点!

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

还有一点我要提的:或许在第二个例子迭代服务器webserver3b.py中你已经注意到了,当服务器进程正在睡那60S的时候,你是否仍然可以使用curl命令和服务器连接?当然,curl不会立刻输出什么东西,他只是等在那儿,但是服务器没有接受连接,客户端也没有立刻被拒绝,却任然能够链接到服务器?答案就是套接字对象的listen()函数和它的BACKLOG参数,在代码中我称之为REQUEST_QUEUE_SIZE。BACKLOG参数决定了内核中新进来的连接请求队列长度。当服务器(webserver3b.py)在睡大觉的时候,你执行的第二个curl命令能连接至服务器,因为在内核中的连接请求队列有足够的空间。

当增大BACKLOG参数的时候,并不会神器的将你的服务器编程一次能处理多个客户端请求的服务器,但设置一个较大的BACKLOG参数,对于一个比较繁忙的服务器来说很重要。这样一来,服务器可以之间从队列中获取一个连接并开始处理客户端请求,而不是等着新连接的建立。

说了这么多,我们该快速回顾一下前面重要的知识点了。

  • 迭代服务器
  • 服务器套接字创建过程(套接字,bind函数,listen函数,accept函数)
  • 客户端连接创建过程(套接字,connect函数)
  • 套接字对
  • 套接字
  • 临时端口和知名端口
  • 进程
  • 进程ID(PID),父进程ID(PPID),进程的父子关系
  • 文件描述符
  • 套接listen函数BACKLOG参数的含义

现在我准备回答Part2中留的作业题了,“怎样才能确保你的服务器能一次处理多请求?”换种说法就是“怎样写一个并发服务器?”

在UNIX下实现并发服务器最简单的方法就是使用系统的fork()函数。

下面是并发服务器webserver3c.py的代码,能够一次处理多个客户端的请求(正如webserver3b.py迭代服务器的例子中,每个子进程要睡60S)

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

在讲fork函数如何工作之前,自己先尝试下,看服务器是否真的能同时处理多客户端请求,而不是像webserver3a.py 和 webserver3b.py那样。使用下面的命令启动服务器:

$ python webserver3c.py

再次尝试使用两个curl命令来访问服务器,像以前访问迭代服务器那样。这次尽管服务器子进程处理完客户端请求会睡60S,但这不影响其他客户端,因为他们是不同的、完全独立的进程。你能看到curl命令立刻输出“Hello World”然后再等60S。进程,你可以想开多少开多少,并且所有的进程都会立刻输出“Hello World”而不会有任何延时。

我仍清晰记得我第一次使用fork的时候有多么激动,就像我从霍格沃兹毕业一样。我一直以为自己读的是线性代码,当这段代码复制了自己,并且实例化了两个同样的代码在并发执行,我的新一下子就融化了。我认为这不单单是魔法,这几乎是黑魔法。

当父进程fork了一个子进程的时候,这个子进程会赋值父进程文件描述符的一个副本。

或许你注意到了代码中,父进程关闭了客户端的连接。

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

那么,即使父进程关了套接字,为什么子进程仍能从客户端套接字读取数据呢?答案在上图中。内核使用描述符计数(descriptor reference counts)来决定是否要关闭一个套接字。只有当文件描述符计数变成0 的时候,内核才会关闭套接字。当你服务器创建了一个子进程,子进程得到了父进程文件描述符的一个副本时,内核才开始为这些文件描述符增加计数。 在一个父进程和一个子进程的情况下,对于客户端套接字,描述符引用计数是2,当上面代码中父进程关闭客户端连接套接字时,它只把引用计数递减为1,不会小到使内核关闭套接字。 子进程也会关闭父进程的listen_socket的副本,因为该子进程并不关心接受新的客户端连接,它仅关心处理来自已建立的客户端的请求:

listen_socket.close()  # close child copy

文章中我会讲到如果不关闭重复的文件描述符会怎样。

从你的并发服务器中源代码可以看出,服务器父进程只是接受一个新客户端连接,派生一个子进程来处理客户端请求,然后重新循环接受另外的客户端连接,仅此而已。服务器父进程不处理客户端请求,只是交给子进程去做。

此外,你知道我说的两件事是并发的是什么意思吗?

当我说两件事是并发的,我通常想说的是两件事是同时发生的。这只是一个简短的定义,你应该记住它严格的定义:

如果你没法分清两件事谁先发生,那么这两件事是并发的。

再来回顾一下知识点:

  • 在UNIX中使用系统函数fork()写一个最简单的并发服务器。
  • 当一个进程fork了新进程,他就成了新进程的父进程。
  • 调用fork函数后,父子进程共享相同的文件描述符。
  • 内核使用文件描述符计数来决定是否要关闭这个文件/套接字
  • 服务器父进程的角色是:所有他要做的就是接受一个客户端的新连接,fork一个子进程来处理这个客户端的请求,然后循环去接受一个新的客户端连接。

接下来我们尝试下不关闭重复的文件描述符,在父子进程中会发生什么。下面是一个修改过的并发服务器,这个服务器不会关闭重复的文件描述符,webserver3d.py

###########################################################################
# 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 webserver3d.py

使用curl命令连接服务器:

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

curl命令打印出了并发服务器的响应,但它没有结束并且保持着连接。发生了什么呢?服务器并没有继续睡60S,它的子进程积极地处理客户端请求,关闭了客户端连接,然后退出了。但是客户端的curl仍没有结束。

但为什么curl没有停止呢?原因是重复的文件描述符。当子进程关闭了客户端连接时,内核减少了客户端套接字的文件描述符计数,这时候文件描述符计数变为1。服务器子进程退出了,但是内核并没有关闭客户端套接字,因为套接字的文件描述符计数不为0,结果没有向客户端发送终止包(在TCP/IP术语中叫FIN),这样一来客户端一直保持在线。还有一个问题,如果你长时间运行服务器并且没有关闭重复的文件描述符,他最终会耗尽可用的文件描述符。

使用Ctrl+C关闭服务器,使用shell自带命令ulimit查看服务器进程可使用的剩余资源。

$ 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

正如上面所示,服务器进程能够使用的最大文件描述符的数量是1024。

现在我们一起看看,如果不关闭文件描述符的话,服务器是怎样用光文件描述符资源的。在终端设置你服务器最大文件描述符数量为256:

$ ulimit -n 256

在同一终端中,启动服务器webserver3d.py

$ python webserver3d.py

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

#####################################################################
# 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 client3.py --max-clients=300

很快你的服务器就会爆炸,下面是我虚拟机的异常。

结果很明显了,你应该关闭重复的文件描述符。尽管你关闭了重复的文件描述符,你仍然没有走出困境,服务器还有一个问题等着你,那就是僵尸进程。

是你的服务器代码创造了这些僵尸进程。重启你的服务器我们一起分析下。

$ python webserver3d.py

在另一个终端使用curl命令。

$ curl http://localhost:8888/hello

运行ps命令找出运行的python进程。下面是我ubuntu虚拟机上的ps命令输出。

$ 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] <defunct>

看上面的第二行,PID是9102的进程,状态是Z+,进程名是<defunct>。那就是服务器创造的僵尸进程。问题是:你没办法杀死僵尸进程。

即使你使用 kill -9 ,你也杀不死它。不信自己试试。

什么是僵尸进程,服务器为什么会创造僵尸进程呢?僵尸进程是这个进程已经关了,但是它的父进程并没有等到它关闭,也没有接收到终止状态。当一个子进程先于它父进程退出,内核就会将这个子进程变成僵尸进程,存储子进程相关信息以供父进程重新使用。存储的信息通常包括PID、进程终止状态、进程使用的资源。看来僵尸进程存在是有目的的。但你不关心这些僵尸进程的话,系统会变得拥塞。一起试一下。首先停下运行着的服务器。在一个新终端,使用ulimit命令讲最大进程设置为400(确保打开的文件数量足够多,我们设置为500)。

$ ulimit -u 400
$ ulimit -n 500

在同一终端启动webserver3d.py

$ python webserver3d.py

在一个新终端,启动客户端client3.py并且创建500个同步连接,连接到服务器。

$ python webserver3d.py

再一次,没多久你的服务器就会因为OSError:Resource temporarily unavailable爆炸。当服务器想要创建一个子进程的时候,因为达到了系统允许服务器创建子进程的最大数量。下面是我虚拟机错误截图。

正如前面所示,如果你不关心僵尸进程的话,他们会给你长时间运行的服务器创造一些麻烦。我会简单说说服务器应该怎么处理僵尸进程。

下面简单回顾一下知识点。

  • 如果你不关闭重复的文件描述符,客户端不会关闭,因为客户端连接没有收到关闭信号。
  • 如果你不关闭重复的文件描述符的还,长时间运行的服务器最终会耗尽可用的文件描述符(即系统所允许的最大打开文件数量)。
  • 当你派生了一个子进程,子进程退出了,父进程没有等子进程退出,也没有获取子进程的结束状态,这样一来,子进程就变成了僵尸进程。
  • 僵尸进程需要消耗一些东西,在本文例子中,僵尸进程消耗的是内存。如果你不关心这些僵尸进程的话,服务器最终会耗尽可使用的进程(即系统所允许的最大进程数)。
  • 你杀不死僵尸进程,只能等他自己结束。

那么你需要怎样关心僵尸进程呢?你需要需改你的服务器代码,来等僵尸进程获取到他们的结束状态。可以通过修改服务器代码,调用一个系统函数wait来实现。遗憾的是这个方法远不够理想。如果你调用wait函数却没有关闭了的子进程,此时wait函数会阻塞你的服务器,等效于阻止你的服务器处理新的客户端连接请求。那么还有别的选项吗?有,确实有。方法之一就是同时使用信号处理(signal handler)函数和wait函数。

下面是它运行原理。当一个子进程退出,内核发送一个SIGCHLD信号。父进程能设置一个信号处理函数(signal handler)异步地等待SIGCHLD事件,同时可以等待子进程以收集子进程的结束状态,如此一来就可以阻止僵尸进程到处闲逛了。

提一句,异步事件意味着父进程并不提前知道事情会发生。

修改服务器代码设置一个SIGCHLD事件处理函数,并且等待事件处理函数中结束的子进程。代码webserver3e.py如下:

###########################################################################
# 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 webserver3e.py

依旧是使用curl命令给修改过的并发服务器发送请求。

$ curl http://localhost:8888/hello

接下来看服务器:

刚才发生什么了?调用accept函数后接收到一个EINTR错误。

当子进程退出时导致了SIGCHLD事件,父进程阻塞在了accept函数调用的地方,这激活了信号处理函数。当信号处理函数结束时,accept函数被中断:

别担心,这只是个非常简单的问题。你所需要做的就是重新调用accept函数,下面时修改过的服务器代码webserver3f.py,可以用来处理这个问题:

###########################################################################
# 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 webserver3f.py

使用curl命令给修改过的并发服务器发送请求:

$ curl http://localhost:8888/hello

看见了吗,不会再有EINTR错误了。现在,验证一下,再也没有僵尸进程了,并且SIGCHLD事件处理函数和wait函数也能处理好关闭的子进程了。使用ps命令,查看一下,在也没有Z+状态的子进程了。没有僵尸进程确实觉得轻松自在了一些。

  • 如果派生一个子进程并且没有等他的话,他会变成一个僵尸进程。
  • 使用SIGCHLD事件处理函数来异步等待一个子进程结束,并收集结束状态。
  • 当使用事件处理函数,你需要记得系统函数可能会中断,你需要为此做好准备。

好的,目前为止没有什么问题了,真的吗?再次启动webserver3f.py,这次不是使用curl发送一个请求,而是使用client3.py创建128个同步连接:

$ python client3.py --max-clients 128

现在使用ps命令查看进程状态:

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

看!僵尸进程又回来了!

这次又是哪里错了呢?当你启动128个客户端建立128连接的时候,服务器子进程处理完请求,几乎是同一时间退出,这就导致了SIGCHLD信号像洪水一样涌向父进程。问题是信号并不是排着队过去的,服务器总会漏掉几个信号,这导致了几个僵尸进程的出现。

解决办法是,在循环中设置SIGCHLD事件处理函数,不用wait函数而是用waitpid函数,并增加WNOHANG选项,以确保所有关闭的子进程都可以进行处理。下面是修改后的服务器代码webserver3g.py

###########################################################################
# 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 webserver3g.py

使用客户端client3.py

$ python client3.py --max-clients 128

使用ps命令来验证一下是否还有僵尸进程。确实没有了,好开熏~

恭喜!你已经开发出了一个简单的并发服务器,并且这段代码可以作为以后开发一个生产级web服务器的基础。
最后还有一个练习题,从Part2的WSGI服务器开发成并发服务器。你可从这里找到这个版本。但是我希望你自己实现了之后再来看这段代码。你需要的所有信息都在上面了,撸起袖子加油干!
接下来做什么呢?Josh Billings说过:像一张邮票一样,坚持一件事直到完成。
掌握基础知识,提出疑问,就此向更深处学习。

如果你只学方法,你会圄于方法;如果你学习原理,你会创造方法。——爱默生

译注:

2 thoughts on “【翻译】一步步开发打造一个Web服务器.Part 3.”

发表评论

电子邮件地址不会被公开。 必填项已用*标注