自己动手开发一个 Web 服务器(三)

简介:

自己动手开发一个 Web 服务器(三)



第二部分中,你开发了一个能够处理HTTPGET请求的简易WSGI服务器。在上一篇的最后,我问了你一个问题:“怎样让服务器一次处理多个请求?”读完本文,你就能够完美地回答这个问题。接下来,请你做好准备,因为本文的内容非常多,节奏也很快。文中的所有代码都可以在Github仓库下载。

首先,我们简单回忆一下简易网络服务器是如何实现的,服务器要处理客户端的请求需要哪些条件。你在前面两部分文章中开发的服务器,是一个迭代式服务器iterative server,还只能一次处理一个客户端请求。只有在处理完当前客户端请求之后,它才能接收新的客户端连接。这样,有些客户端就必须要等待自己的请求被处理了,而对于流量大的服务器来说,等待的时间就会特别长。

客户端逐个等待服务器响应

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


  
  
  1. #####################################################################
  2. # Iterative server - webserver3a.py #
  3. # #
  4. # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X #
  5. #####################################################################
  6. import socket
  7. SERVER_ADDRESS = (HOST, PORT) = '', 8888
  8. REQUEST_QUEUE_SIZE = 5
  9. def handle_request(client_connection):
  10. request = client_connection.recv(1024)
  11. print(request.decode())
  12. http_response = b"""\
  13. HTTP/1.1 200 OK
  14. Hello, World!
  15. """
  16. client_connection.sendall(http_response)
  17. def serve_forever():
  18. listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  19. listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  20. listen_socket.bind(SERVER_ADDRESS)
  21. listen_socket.listen(REQUEST_QUEUE_SIZE)
  22. print('Serving HTTP on port {port} ...'.format(port=PORT))
  23. while True:
  24. client_connection, client_address = listen_socket.accept()
  25. handle_request(client_connection)
  26. client_connection.close()
  27. if __name__ == '__main__':
  28. serve_forever()

如果想确认这个服务器每次只能处理一个客户端的请求,我们对上述代码作简单修改,在向客户端返回响应之后,增加60秒的延迟处理时间。这个修改只有一行代码,即告诉服务器在返回响应之后睡眠60秒。

让服务器睡眠60秒

下面就是修改之后的服务器代码:


  
  
  1. #########################################################################
  2. # Iterative server - webserver3b.py #
  3. # #
  4. # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X #
  5. # #
  6. # - Server sleeps for 60 seconds after sending a response to a client #
  7. #########################################################################
  8. import socket
  9. import time
  10. SERVER_ADDRESS = (HOST, PORT) = '', 8888
  11. REQUEST_QUEUE_SIZE = 5
  12. def handle_request(client_connection):
  13. request = client_connection.recv(1024)
  14. print(request.decode())
  15. http_response = b"""\
  16. HTTP/1.1 200 OK
  17. Hello, World!
  18. """
  19. client_connection.sendall(http_response)
  20. time.sleep(60) # sleep and block the process for 60 seconds
  21. def serve_forever():
  22. listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  23. listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  24. listen_socket.bind(SERVER_ADDRESS)
  25. listen_socket.listen(REQUEST_QUEUE_SIZE)
  26. print('Serving HTTP on port {port} ...'.format(port=PORT))
  27. while True:
  28. client_connection, client_address = listen_socket.accept()
  29. handle_request(client_connection)
  30. client_connection.close()
  31. if __name__ == '__main__':
  32. serve_forever()

接下来,我们启动服务器:


  
  
  1. $ python webserver3b.py

现在,我们打开一个新的终端窗口,并运行curl命令。你会立刻看到屏幕上打印出了“Hello, World!”这句话:


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

接着我们立刻再打开一个终端窗口,并运行curl命令:


  
  
  1. $ curl http://localhost:8888/hello

如果你在60秒了完成了上面的操作,那么第二个curl命令应该不会立刻产生任何输出结果,而是处于挂死(hang)状态。服务器也不会在标准输出中打印这个新请求的正文。下面这张图就是我在自己的Mac上操作时的结果(右下角那个边缘高亮为黄色的窗口,显示的就是第二个curl命令挂死):

Mac上操作时的结果

当然,你等了足够长时间之后(超过60秒),你会看到第一个curl命令结束,然后第二个curl命令会在屏幕上打印出“Hello, World!”,之后再挂死60秒,最后才结束:

curl命令演示

这背后的实现方式是,服务器处理完第一个curl客户端请求后睡眠60秒,才开始处理第二个请求。这些步骤是线性执行的,或者说迭代式一步一步执行的。在我们这个实例中,则是一次一个请求这样处理。

接下来,我们简单谈谈客户端与服务器之间的通信。为了让两个程序通过网络进行通信,二者均必须使用套接字。你在前两章中也看到过套接字,但到底什么是套接字?

什么是套接字

套接字是通信端点communication endpoint的抽象形式,可以让一个程序通过文件描述符file descriptor与另一个程序进行通信。在本文中,我只讨论Linux/Mac OS X平台上的TCP/IP套接字。其中,尤为重要的一个概念就是TCP套接字对socket pair

TCP连接所使用的套接字对是一个4元组4-tuple,包括本地IP地址、本地端口、外部IP地址和外部端口。一个网络中的每一个TCP连接,都拥有独特的套接字对。IP地址和端口号通常被称为一个套接字,二者一起标识了一个网络端点。

套接字对合套接字

因此,{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. 然后,服务器绑定地址。绑定函数为套接字指定一个本地协议地址。调用绑定函数时,你可以单独指定端口号或IP地址,也可以同时指定两个参数,甚至不提供任何参数也没问题。

    listen_socket.bind(SERVER_ADDRESS)

  4. 接着,服务器将该套接字变成一个侦听套接字:

    listen_socket.listen(REQUEST_QUEUE_SIZE)

listen方法只能由服务器调用,执行后会告知服务器应该接收针对该套接字的连接请求。

完成上面四步之后,服务器会开启一个循环,开始接收客户端连接,不过一次只接收一个连接。当有连接请求时,accept方法会返回已连接的客户端套接字。然后,服务器从客户端套接字读取请求数据,在标准输出中打印数据,并向客户端返回消息。最后,服务器会关闭当前的客户端连接,这时服务器又可以接收新的客户端连接了。

要通过TCP/IP协议与服务器进行通信,客户端需要作如下操作:

客户端与服务器进行通信所需要的操作

下面这段示例代码,实现了客户端连接至服务器,发送请求,并打印响应内容的过程:


  
  
  1. import socket
  2. # create a socket and connect to a server
  3. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  4. sock.connect(('localhost', 8888))
  5. # send and receive some data
  6. sock.sendall(b'test')
  7. data = sock.recv(1024)
  8. print(data.decode())

在创建套接字之后,客户端需要与服务器进行连接,这可以通过调用connect方法实现:


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

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

你可能已经注意到,客户端不会调用bindaccept方法。不需要调用bind方法,是因为客户端不关心本地IP地址和本地端口号。客户端调用connect方法时,系统内核中的TCP/IP栈会自动指定本地IP地址和本地端口。本地端口也被称为临时端口ephemeral port

本地端口——临时端口号

服务器端有部分端口用于连接熟知的服务,这种端口被叫做“熟知端口”well-known port,例如,80用于HTTP传输服务,22用于SSH协议传输。接下来,我们打开Python shell,向在本地运行的服务器发起一个客户端连接,然后查看系统内核为你创建的客户端套接字指定了哪个临时端口(在进行下面的操作之前,请先运行webserver3a.pywebserver3b.py文件,启动服务器):


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

在上面的示例中,我们看到内核为套接字指定的临时端口是60589。

在开始回答第二部分最后提的问题之前,我需要快速介绍一些其他的重要概念。稍后你就会明白我为什么要这样做。我要介绍的重要概念就是进程process文件描述符file descriptor

什么是进程?进程就是正在执行的程序的一个实例。举个例子,当服务器代码执行的时候,这些代码就被加载至内存中,而这个正在被执行的服务器的实例就叫做进程。系统内核会记录下有关进程的信息——包括进程ID,以便进行管理。所以,当你运行迭代式服务器webserver3a.pywebserver3b.py时,你也就开启了一个进程。

服务器进程

我们在终端启动webserver3a.py服务器:


  
  
  1. $ python webserver3b.py

然后,我们在另一个终端窗口中,使用ps命令来获取上面那个服务器进程的信息:


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

ps命令的结果,我们可以看出你的确只运行了一个Python进程webserver3b。进程创建的时候,内核会给它指定一个进程ID——PID。在UNIX系统下,每个用户进程都会有一个父进程parent process,而这个父进程也有自己的进程ID,叫做父进程ID,简称PPID。在本文中,我默认大家使用的是BASH,因此当你启动服务器的时候,系统会创建服务器进程,指定一个PID,而服务器进程的父进程PID则是BASH shell进程的PID。

进程ID与父进程ID

接下来请自己尝试操作一下。再次打开你的Python shell程序,这会创建一个新进程,然后我们通过os.gepid()os.getppid()这两个方法,分别获得Python shell进程的PID及它的父进程PID(即BASH shell程序的PID)。接着,我们打开另一个终端窗口,运行ps命令,grep检索刚才所得到的PPID(父进程ID,本操作时的结果是3148)。在下面的截图中,你可以看到我在Mac OS X上的操作结果:

Mac OS X系统下进程ID与父进程ID演示

另一个需要掌握的重要概念就是文件描述符file descriptor。那么,到底什么是文件描述符?文件描述符指的就是当系统打开一个现有文件、创建一个新文件或是创建一个新的套接字之后,返回给进程的那个正整型数。系统内核通过文件描述符来追踪一个进程所打开的文件。当你需要读写文件时,你也通过文件描述符说明。Python语言中提供了用于处理文件(和套接字)的高层级对象,所以你不必直接使用文件描述符来指定文件,但是从底层实现来看,UNIX系统中就是通过它们的文件描述符来确定文件和套接字的。

文件描述符

一般来说,UNIX shell会将文件描述符0指定给进程的标准输出,文件描述富1指定给进程的标准输出,文件描述符2指定给标准错误。

标准输入的文件描述符

正如我前面提到的那样,即使Python语言提供了高层及的文件或类文件对象,你仍然可以对文件对象使用fileno()方法,来获取该文件相应的文件描述符。我们回到Python shell中来试验一下。


  
  
  1. >>> import sys
  2. >>> sys.stdin
  3. <open file '<stdin>', mode 'r' at 0x102beb0c0>
  4. >>> sys.stdin.fileno()
  5. 0
  6. >>> sys.stdout.fileno()
  7. 1
  8. >>> sys.stderr.fileno()
  9. 2

在Python语言中处理文件和套接字时,你通常只需要使用高层及的文件/套接字对象即可,但是有些时候你也可能需要直接使用文件描述符。下面这个示例演示了你如何通过write()方法向标准输出中写入一个字符串,而这个write方法就接受文件描述符作为自己的参数:


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

还有一点挺有意思——如果你知道Unix系统下一切都是文件,那么你就不会觉得奇怪了。当你在Python中创建一个套接字后,你获得的是一个套接字对象,而不是一个正整型数,但是你还是可以和上面演示的一样,通过fileno()方法直接访问这个套接字的文件描述符。


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

我还想再说一点:不知道大家有没有注意到,在迭代式服务器webserver3b.py的第二个示例中,我们的服务器在处理完请求后睡眠60秒,但是在睡眠期间,我们仍然可以通过curl命令与服务器建立连接?当然,curl命令并没有立刻输出结果,只是出于挂死状态,但是为什么服务器既然没有接受新的连接,客户端也没有立刻被拒绝,而是仍然继续连接至服务器呢?这个问题的答案在于套接字对象的listen方法,以及它使用的BACKLOG参数。在示例代码中,这个参数的值被我设置为REQUEST_QUEQUE_SIZEBACKLOG参数决定了内核中外部连接请求的队列大小。当webserver3b.py服务器睡眠时,你运行的第二个curl命令之所以能够连接服务器,是因为连接请求队列仍有足够的位置。

虽然提高BACKLOG参数的值并不会让你的服务器一次处理多个客户端请求,但是业务繁忙的服务器也应该设置一个较大的BACKLOG参数值,这样accept函数就可以直接从队列中获取新连接,立刻开始处理客户端请求,而不是还要花时间等待连接建立。

呜呼!到目前为止,已经给大家介绍了很多知识。我们现在快速回顾一下之前的内容。

  • 迭代式服务器
  • 服务器套接字创建流程(socket, bind, listen, accept)
  • 客户端套接字创建流程(socket, connect)
  • 套接字对Socket pair
  • 套接字
  • 临时端口Ephemeral port熟知端口well-known port
  • 进程
  • 进程ID(PID),父进程ID(PPID)以及父子关系
  • 文件描述符File descriptors
  • 套接字对象的listen方法中BACKLOG参数的意义

现在,我可以开始回答第二部分留下的问题了:如何让服务器一次处理多个请求?换句话说,如何开发一个并发服务器?

并发服务器手绘演示

在Unix系统中开发一个并发服务器的最简单方法,就是调用系统函数fork()

fork()系统函数调用

下面就是崭新的webserver3c.py并发服务器,能够同时处理多个客户端请求:


  
  
  1. ###########################################################################
  2. # Concurrent server - webserver3c.py #
  3. # #
  4. # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X #
  5. # #
  6. # - Child process sleeps for 60 seconds after handling a client's request #
  7. # - Parent and child processes close duplicate descriptors #
  8. # #
  9. ###########################################################################
  10. import os
  11. import socket
  12. import time
  13. SERVER_ADDRESS = (HOST, PORT) = '', 8888
  14. REQUEST_QUEUE_SIZE = 5
  15. def handle_request(client_connection):
  16. request = client_connection.recv(1024)
  17. print(
  18. 'Child PID: {pid}. Parent PID {ppid}'.format(
  19. pid=os.getpid(),
  20. ppid=os.getppid(),
  21. )
  22. )
  23. print(request.decode())
  24. http_response = b"""\
  25. HTTP/1.1 200 OK
  26. Hello, World!
  27. """
  28. client_connection.sendall(http_response)
  29. time.sleep(60)
  30. def serve_forever():
  31. listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  32. listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  33. listen_socket.bind(SERVER_ADDRESS)
  34. listen_socket.listen(REQUEST_QUEUE_SIZE)
  35. print('Serving HTTP on port {port} ...'.format(port=PORT))
  36. print('Parent PID (PPID): {pid}\n'.format(pid=os.getpid()))
  37. while True:
  38. client_connection, client_address = listen_socket.accept()
  39. pid = os.fork()
  40. if pid == 0: # child
  41. listen_socket.close() # close child copy
  42. handle_request(client_connection)
  43. client_connection.close()
  44. os._exit(0) # child exits here
  45. else: # parent
  46. client_connection.close() # close parent copy and loop over
  47. if __name__ == '__main__':
  48. serve_forever()

在讨论fork的工作原理之前,请测试一下上面的代码,亲自确认一下服务器是否能够同时处理多个客户端请求。我们通过命令行启动上面这个服务器:


  
  
  1. $ python webserver3c.py

然后输入之前迭代式服务器示例中的两个curl命令。现在,即使服务器子进程在处理完一个客户端请求之后会睡眠60秒,但是并不会影响其他客户端,因为它们由不同的、完全独立的进程处理。你应该可以立刻看见curl命令输出“Hello, World”,然后挂死60秒。你可以继续运行更多的curl命令,所有的命令都会输出服务器的响应结果——“Hello, World”,不会有任何延迟。你可以试试。

关于fork()函数有一点最为重要,就是你调用fork一次,但是函数却会返回两次:一次是在父进程里返回,另一次是在子进程中返回。当你fork一个进程时,返回给子进程的PID是0,而fork返回给父进程的则是子进程的PID。

fork函数

我还记得,第一次接触并使用fork函数时,自己感到非常不可思议。我觉得这就好像一个魔法。之前还是一个线性的代码,突然一下子克隆了自己,出现了并行运行的相同代码的两个实例。我当时真的觉得这和魔法也差不多了。

当父进程fork一个新的子进程时,子进程会得到父进程文件描述符的副本:

当父进程fork一个新的子进程时,子进程会得到父进程文件描述符的副本

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


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

那为什么父进程关闭了套接字之后,子进程却仍然能够从客户端套接字中读取数据呢?答案就在上面的图片里。系统内核根据文件描述符计数descriptor reference counts来决定是否关闭套接字。系统只有在描述符计数变为0时,才会关闭套接字。当你的服务器创建一个子进程时,子进程就会获得父进程文件描述符的副本,系统内核则会增加这些文件描述符的计数。在一个父进程和一个子进程的情况下,客户端套接字的文件描述符计数为2。当上面代码中的父进程关闭客户端连接套接字时,只是让套接字的计数减为1,还不够让系统关闭套接字。子进程同样关闭了父进程侦听套接字的副本,因为子进程不关心要不要接收新的客户端连接,只关心如何处理连接成功的客户端所发出的请求。


  
  
  1. listen_socket.close() # close child copy

稍后,我会给大家介绍如果不关闭重复的描述符的后果。

从上面并行服务器的源代码可以看出,服务器父进程现在唯一的作用,就是接受客户端连接,fork一个新的子进程来处理该客户端连接,然后回到循环的起点,准备接受其他的客户端连接,仅此而已。服务器父进程并不会处理客户端请求,而是由它的子进程来处理。

谈得稍远一点。我们说两个事件是并行时,到底是什么意思?

并行事件

我们说两个事件是并行的,通常指的是二者同时发生。这是简单的定义,但是你应该牢记它的严格定义:

如果你不能分辨出哪个程序会先执行,那么二者就是并行的。

现在又到了回顾目前已经介绍的主要观点和概念。

checkpoint

  • Unix系统中开发并行服务器最简单的方法,就是调用fork()函数
  • 当一个进程fork新进程时,它就成了新创建进程的父进程
  • 在调用fork之后,父进程和子进程共用相同的文件描述符
  • 系统内核通过描述符计数来决定是否关闭文件/套接字
  • 服务器父进程的角色:它现在所做的只是接收来自客户端的新连接,fork一个子进程来处理该客户端的请求,然后回到循环的起点,准备接受新的客户端连接

接下来,我们看看如果不关闭父进程和子进程中的重复套接字描述符,会发生什么情况。下面的并行服务器(webserver3d.py)作了一些修改,确保服务器不关闭重复的:


  
  
  1. ###########################################################################
  2. # Concurrent server - webserver3d.py #
  3. # #
  4. # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X #
  5. ###########################################################################
  6. import os
  7. import socket
  8. SERVER_ADDRESS = (HOST, PORT) = '', 8888
  9. REQUEST_QUEUE_SIZE = 5
  10. def handle_request(client_connection):
  11. request = client_connection.recv(1024)
  12. http_response = b"""\
  13. HTTP/1.1 200 OK
  14. Hello, World!
  15. """
  16. client_connection.sendall(http_response)
  17. def serve_forever():
  18. listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  19. listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  20. listen_socket.bind(SERVER_ADDRESS)
  21. listen_socket.listen(REQUEST_QUEUE_SIZE)
  22. print('Serving HTTP on port {port} ...'.format(port=PORT))
  23. clients = []
  24. while True:
  25. client_connection, client_address = listen_socket.accept()
  26. # store the reference otherwise it's garbage collected
  27. # on the next loop run
  28. clients.append(client_connection)
  29. pid = os.fork()
  30. if pid == 0: # child
  31. listen_socket.close() # close child copy
  32. handle_request(client_connection)
  33. client_connection.close()
  34. os._exit(0) # child exits here
  35. else: # parent
  36. # client_connection.close()
  37. print(len(clients))
  38. if __name__ == '__main__':
  39. serve_forever()

启动服务器:


  
  
  1. $ python webserver3d.py

然后通过curl命令连接至服务器:


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

我们看到,curl命令打印了并行服务器的响应内容,但是并没有结束,而是继续挂死。服务器出现了什么不同情况吗?服务器不再继续睡眠60秒:它的子进程会积极处理客户端请求,处理完成后就关闭客户端连接,然后结束运行,但是客户端的curl命令却不会终止。

服务器不再睡眠,其子进程积极处理客户端请求

那么为什么curl命令会没有结束运行呢?原因在于重复的文件描述符duplicate file descriptor。当子进程关闭客户端连接时,系统内核会减少客户端套接字的计数,变成了1。服务器子进程结束了,但是客户端套接字并没有关闭,因为那个套接字的描述符计数并没有变成0,导致系统没有向客户端发送终止包termination packet(用TCP/IP的术语来说叫做FIN),也就是说客户端仍然在线。但是还有另一个问题。如果你一直运行的服务器不去关闭重复的文件描述符,服务器最终就会耗光可用的文件服务器:

文件描述符

按下Control-C,关闭webserver3d.py服务器,然后通过shell自带的ulimit命令查看服务器进程可以使用的默认资源:


  
  
  1. $ ulimit -a
  2. core file size (blocks, -c) 0
  3. data seg size (kbytes, -d) unlimited
  4. scheduling priority (-e) 0
  5. file size (blocks, -f) unlimited
  6. pending signals (-i) 3842
  7. max locked memory (kbytes, -l) 64
  8. max memory size (kbytes, -m) unlimited
  9. open files (-n) 1024
  10. pipe size (512 bytes, -p) 8
  11. POSIX message queues (bytes, -q) 819200
  12. real-time priority (-r) 0
  13. stack size (kbytes, -s) 8192
  14. cpu time (seconds, -t) unlimited
  15. max user processes (-u) 3842
  16. virtual memory (kbytes, -v) unlimited
  17. file locks (-x) unlimited

从上面的结果中,我们可以看到:在我这台Ubuntu电脑上,服务器进程可以使用的文件描述符(打开的文件)最大数量为1024。

现在,我们来看看如果服务器不关闭重复的文件描述符,服务器会不会耗尽可用的文件描述符。我们在现有的或新开的终端窗口里,将服务器可以使用的最大文件描述符数量设置为256:


  
  
  1. $ ulimit -n 256

在刚刚运行了$ ulimit -n 256命令的终端里,我们开启webserver3d.py服务器:


  
  
  1. $ python webserver3d.py

然后通过下面的client3.py客户端来测试服务器。


  
  
  1. #####################################################################
  2. # Test client - client3.py #
  3. # #
  4. # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X #
  5. #####################################################################
  6. import argparse
  7. import errno
  8. import os
  9. import socket
  10. SERVER_ADDRESS = 'localhost', 8888
  11. REQUEST = b"""\
  12. GET /hello HTTP/1.1
  13. Host: localhost:8888
  14. """
  15. def main(max_clients, max_conns):
  16. socks = []
  17. for client_num in range(max_clients):
  18. pid = os.fork()
  19. if pid == 0:
  20. for connection_num in range(max_conns):
  21. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  22. sock.connect(SERVER_ADDRESS)
  23. sock.sendall(REQUEST)
  24. socks.append(sock)
  25. print(connection_num)
  26. os._exit(0)
  27. if __name__ == '__main__':
  28. parser = argparse.ArgumentParser(
  29. description='Test client for LSBAWS.',
  30. formatter_class=argparse.ArgumentDefaultsHelpFormatter,
  31. )
  32. parser.add_argument(
  33. '--max-conns',
  34. type=int,
  35. default=1024,
  36. help='Maximum number of connections per client.'
  37. )
  38. parser.add_argument(
  39. '--max-clients',
  40. type=int,
  41. default=1,
  42. help='Maximum number of clients.'
  43. )
  44. args = parser.parse_args()
  45. main(args.max_clients, args.max_conns)

打开一个新终端窗口,运行client3.py,并让客户端创建300个与服务器的并行连接:


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

很快你的服务器就会崩溃。下面是我的虚拟机上抛出的异常情况:

服务器连接过多

问题很明显——服务器应该关闭重复的描述符。但即使你关闭了这些重复的描述符,你还没有彻底解决问题,因为你的服务器还存在另一个问题,那就是僵尸进程!

僵尸进程

没错,你的服务器代码确实会产生僵尸进程。我们来看看这是怎么回事。再次运行服务器:


  
  
  1. $ python webserver3d.py

在另一个终端窗口中运行下面的curl命令:


  
  
  1. $ curl http://localhost:8888/hello

现在,我们运行ps命令,看看都有哪些正在运行的Python进程。下面是我的Ubuntu虚拟机中的结果:


  
  
  1. $ ps auxw | grep -i python | grep -v grep
  2. vagrant 9099 0.0 1.2 31804 6256 pts/0 S+ 16:33 0:00 python webserver3d.py
  3. vagrant 9102 0.0 0.0 0 0 pts/0 Z+ 16:33 0:00 [python] <defunct>

我们发现,第二行中显示的这个进程的PID为9102,状态是Z+,而进程的名称叫做<defunct>。这就是我们要找的僵尸进程。僵尸进程的问题在于你无法杀死它们。

僵尸进程无法被杀死

即使你试图通过$ kill -9命令杀死僵尸进程,它们还是会存活下来。你可以试试看。

到底什么是僵尸进程,服务器又为什么会创建这些进程?僵尸进程其实是已经结束了的进程,但是它的父进程并没有等待进程结束,所以没有接收到进程结束的状态信息。当子进程在父进程之前退出,系统就会将子进程变成一个僵尸进程,保留原子进程的部分信息,方便父进程之后获取。系统所保留的信息通常包括进程ID、进程结束状态和进程的资源使用情况。好吧,这样说僵尸进程也有自己存在的理由,但是如果服务器不处理好这些僵尸进程,系统就会堵塞。我们来看看是否如此。首先,停止正在运行的服务器,然后在新终端窗口中,使用ulimit命令将最大用户进程设置为400(还要确保将打开文件数量限制设置到一个较高的值,这里我们设置为500)。


  
  
  1. $ ulimit -u 400
  2. $ ulimit -n 500

然后在同一个窗口中启动webserver3d.py服务器:


  
  
  1. $ python webserver3d.py

在新终端窗口中,启动客户端client3.py,让客户端创建500个服务器并行连接:


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

结果,我们发现很快服务器就因为OSError而崩溃:这个异常指的是暂时没有足够的资源。服务器试图创建新的子进程时,由于已经达到了系统所允许的最大可创建子进程数,所以抛出这个异常。下面是我的虚拟机上的报错截图。

OSError异常

你也看到了,如果长期运行的服务器不处理好僵尸进程,将会出现重大问题。稍后我会介绍如何处理僵尸进程。

我们先回顾一下目前已经学习的知识点:

  • 如果你不关闭重复的文件描述符,由于客户端连接没有中断,客户端程序就不会结束。
  • 如果你不关闭重复的文件描述符,你的服务器最终会消耗完可用的文件描述符(最大打开文件数)
  • 当你fork一个子进程后,如果子进程在父进程之前退出,而父进程又没有等待进程,并获取它的结束状态,那么子进程就会变成僵尸进程。
  • 僵尸进程也需要消耗资源,也就是内存。如果不处理好僵尸进程,你的服务器最终会消耗完可用的进程数(最大用户进程数)。
  • 你无法杀死僵尸进程,你需要等待子进程结束。

那么,你要怎么做才能处理掉僵尸进程呢?你需要修改服务器代码,等待僵尸进程返回其结束状态termination status。要实现这点,你只需要在代码中调用wait系统函数即可。不过,这种方法并不是最理想的方案,因为如果你调用wait后,却没有结束了的子进程,那么wait调用将会阻塞服务器,相当于阻止了服务器处理新的客户端请求。那么还有其他的办法吗?答案是肯定的,其中一种办法就是将wait函数调用与信号处理函数signal handler结合使用。

信号处理函数

这种方法的具体原理如下。当子进程退出时,系统内核会发送一个SIGCHLD信号。父进程可以设置一个信号处理函数,用于异步监测SIGCHLD事件,然后再调用wait,等待子进程结束并获取其结束状态,这样就可以避免产生僵尸进程。

SIGCHLD信号与wait函数结合使用

顺便说明一下,异步事件意味着父进程实现并不知道该事件是否会发生。

接下来我们修改服务器代码,添加一个SIGCHLD事件处理函数,并在该函数中等待子进程结束。具体的代码见webserver3e.py文件:


  
  
  1. ###########################################################################
  2. # Concurrent server - webserver3e.py #
  3. # #
  4. # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X #
  5. ###########################################################################
  6. import os
  7. import signal
  8. import socket
  9. import time
  10. SERVER_ADDRESS = (HOST, PORT) = '', 8888
  11. REQUEST_QUEUE_SIZE = 5
  12. def grim_reaper(signum, frame):
  13. pid, status = os.wait()
  14. print(
  15. 'Child {pid} terminated with status {status}'
  16. '\n'.format(pid=pid, status=status)
  17. )
  18. def handle_request(client_connection):
  19. request = client_connection.recv(1024)
  20. print(request.decode())
  21. http_response = b"""\
  22. HTTP/1.1 200 OK
  23. Hello, World!
  24. """
  25. client_connection.sendall(http_response)
  26. # sleep to allow the parent to loop over to 'accept' and block there
  27. time.sleep(3)
  28. def serve_forever():
  29. listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  30. listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  31. listen_socket.bind(SERVER_ADDRESS)
  32. listen_socket.listen(REQUEST_QUEUE_SIZE)
  33. print('Serving HTTP on port {port} ...'.format(port=PORT))
  34. signal.signal(signal.SIGCHLD, grim_reaper)
  35. while True:
  36. client_connection, client_address = listen_socket.accept()
  37. pid = os.fork()
  38. if pid == 0: # child
  39. listen_socket.close() # close child copy
  40. handle_request(client_connection)
  41. client_connection.close()
  42. os._exit(0)
  43. else: # parent
  44. client_connection.close()
  45. if __name__ == '__main__':
  46. serve_forever()

启动服务器:


  
  
  1. $ python webserver3e.py

再次使用curl命令,向修改后的并发服务器发送一个请求:


  
  
  1. $ curl http://localhost:8888/hello

我们来看服务器的反应:

修改后的并发服务器处理请求

发生了什么事?accept函数调用报错了。

accept函数调用失败

子进程退出时,父进程被阻塞在accept函数调用的地方,但是子进程的退出导致了SIGCHLD事件,这也激活了信号处理函数。信号函数执行完毕之后,就导致了accept系统函数调用被中断:

accept调用被中断

别担心,这是个非常容易解决的问题。你只需要重新调用accept即可。下面我们再修改一下服务器代码(webserver3f.py),就可以解决这个问题:


  
  
  1. ###########################################################################
  2. # Concurrent server - webserver3f.py #
  3. # #
  4. # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X #
  5. ###########################################################################
  6. import errno
  7. import os
  8. import signal
  9. import socket
  10. SERVER_ADDRESS = (HOST, PORT) = '', 8888
  11. REQUEST_QUEUE_SIZE = 1024
  12. def grim_reaper(signum, frame):
  13. pid, status = os.wait()
  14. def handle_request(client_connection):
  15. request = client_connection.recv(1024)
  16. print(request.decode())
  17. http_response = b"""\
  18. HTTP/1.1 200 OK
  19. Hello, World!
  20. """
  21. client_connection.sendall(http_response)
  22. def serve_forever():
  23. listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  24. listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  25. listen_socket.bind(SERVER_ADDRESS)
  26. listen_socket.listen(REQUEST_QUEUE_SIZE)
  27. print('Serving HTTP on port {port} ...'.format(port=PORT))
  28. signal.signal(signal.SIGCHLD, grim_reaper)
  29. while True:
  30. try:
  31. client_connection, client_address = listen_socket.accept()
  32. except IOError as e:
  33. code, msg = e.args
  34. # restart 'accept' if it was interrupted
  35. if code == errno.EINTR:
  36. continue
  37. else:
  38. raise
  39. pid = os.fork()
  40. if pid == 0: # child
  41. listen_socket.close() # close child copy
  42. handle_request(client_connection)
  43. client_connection.close()
  44. os._exit(0)
  45. else: # parent
  46. client_connection.close() # close parent copy and loop over
  47. if __name__ == '__main__':
  48. serve_forever()

启动修改后的服务器:


  
  
  1. $ python webserver3f.py

通过curl命令向服务器发送一个请求:


  
  
  1. $ curl http://localhost:8888/hello

看到了吗?没有再报错了。现在,我们来确认下服务器没有再产生僵尸进程。只需要运行ps命令,你就会发现没有Python进程的状态是Z+了。太棒了!没有僵尸进程捣乱真是太好了。

checkpoint

  • 如果你fork一个子进程,却不等待进程结束,该进程就会变成僵尸进程。
  • 使用SIGCHLD时间处理函数来异步等待进程结束,获取其结束状态。
  • 使用事件处理函数时,你需要牢记系统函数调用可能会被中断,要做好这类情况发生得准备。

好了,目前一切正常。没有其他问题了,对吗?呃,基本上是了。再次运行webserver3f.py,然后通过client3.py创建128个并行连接:


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

现在再次运行ps命令:


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

噢,糟糕!僵尸进程又出现了!

僵尸进程又出现了

这次又是哪里出了问题?当你运行128个并行客户端,建立128个连接时,服务器的子进程处理完请求,几乎是同一时间退出的,这就触发了一大波的SIGCHLD信号发送至父进程。但问题是这些信号并没有进入队列,所以有几个信号漏网,没有被服务器处理,这就导致出现了几个僵尸进程。

部分信号没有被处理,导致出现僵尸进程

这个问题的解决方法,就是在SIGCHLD事件处理函数使用waitpid,而不是wait,再调用waitpid时增加WNOHANG选项,确保所有退出的子进程都会被处理。下面就是修改后的代码,webserver3g.py:


  
  
  1. ###########################################################################
  2. # Concurrent server - webserver3g.py #
  3. # #
  4. # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X #
  5. ###########################################################################
  6. import errno
  7. import os
  8. import signal
  9. import socket
  10. SERVER_ADDRESS = (HOST, PORT) = '', 8888
  11. REQUEST_QUEUE_SIZE = 1024
  12. def grim_reaper(signum, frame):
  13. while True:
  14. try:
  15. pid, status = os.waitpid(
  16. -1, # Wait for any child process
  17. os.WNOHANG # Do not block and return EWOULDBLOCK error
  18. )
  19. except OSError:
  20. return
  21. if pid == 0: # no more zombies
  22. return
  23. def handle_request(client_connection):
  24. request = client_connection.recv(1024)
  25. print(request.decode())
  26. http_response = b"""\
  27. HTTP/1.1 200 OK
  28. Hello, World!
  29. """
  30. client_connection.sendall(http_response)
  31. def serve_forever():
  32. listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  33. listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  34. listen_socket.bind(SERVER_ADDRESS)
  35. listen_socket.listen(REQUEST_QUEUE_SIZE)
  36. print('Serving HTTP on port {port} ...'.format(port=PORT))
  37. signal.signal(signal.SIGCHLD, grim_reaper)
  38. while True:
  39. try:
  40. client_connection, client_address = listen_socket.accept()
  41. except IOError as e:
  42. code, msg = e.args
  43. # restart 'accept' if it was interrupted
  44. if code == errno.EINTR:
  45. continue
  46. else:
  47. raise
  48. pid = os.fork()
  49. if pid == 0: # child
  50. listen_socket.close() # close child copy
  51. handle_request(client_connection)
  52. client_connection.close()
  53. os._exit(0)
  54. else: # parent
  55. client_connection.close() # close parent copy and loop over
  56. if __name__ == '__main__':
  57. serve_forever()

启动服务器:


  
  
  1. $ python webserver3g.py

使用客户端client3.py进行测试:


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

现在请确认不会再出现僵尸进程了。

不会再出现僵尸进程了

恭喜大家!现在已经自己开发了一个简易的并发服务器,这个代码可以作为你以后开发生产级别的网络服务器的基础。

最后给大家留一个练习题,把第二部分中的WSGI修改为并发服务器。最终的代码可以在这里查看。不过请你在自己实现了之后再查看。

接下来该怎么办?借用乔希·比林斯(19世纪著名幽默大师)的一句话:

要像一张邮票,坚持一件事情直到你到达目的地。

坚持就是胜利




本文来自云栖社区合作伙伴“Linux中国”

原文发布时间为:2013-04-02.

相关文章
|
6天前
|
存储 人工智能 自然语言处理
ChatMCP:基于 MCP 协议开发的 AI 聊天客户端,支持多语言和自动化安装 MCP 服务器
ChatMCP 是一款基于模型上下文协议(MCP)的 AI 聊天客户端,支持多语言和自动化安装。它能够与多种大型语言模型(LLM)如 OpenAI、Claude 和 OLLama 等进行交互,具备自动化安装 MCP 服务器、SSE 传输支持、自动选择服务器、聊天记录管理等功能。
62 14
ChatMCP:基于 MCP 协议开发的 AI 聊天客户端,支持多语言和自动化安装 MCP 服务器
|
14天前
|
前端开发 安全 JavaScript
2025年,Web3开发学习路线全指南
本文提供了一条针对Dapp应用开发的学习路线,涵盖了Web3领域的重要技术栈,如区块链基础、以太坊技术、Solidity编程、智能合约开发及安全、web3.js和ethers.js库的使用、Truffle框架等。文章首先分析了国内区块链企业的技术需求,随后详细介绍了每个技术点的学习资源和方法,旨在帮助初学者系统地掌握Dapp开发所需的知识和技能。
2025年,Web3开发学习路线全指南
|
21天前
|
存储 前端开发 JavaScript
如何在项目中高效地进行 Web 组件化开发
高效地进行 Web 组件化开发需要从多个方面入手,通过明确目标、合理规划、规范开发、加强测试等一系列措施,实现组件的高效管理和利用,从而提高项目的整体开发效率和质量,为用户提供更好的体验。
27 7
|
25天前
|
开发框架 搜索推荐 数据可视化
Django框架适合开发哪种类型的Web应用程序?
Django 框架凭借其强大的功能、稳定性和可扩展性,几乎可以适应各种类型的 Web 应用程序开发需求。无论是简单的网站还是复杂的企业级系统,Django 都能提供可靠的支持,帮助开发者快速构建高质量的应用。同时,其活跃的社区和丰富的资源也为开发者在项目实施过程中提供了有力的保障。
|
24天前
|
开发框架 JavaScript 前端开发
TypeScript 是一种静态类型的编程语言,它扩展了 JavaScript,为 Web 开发带来了强大的类型系统、组件化开发支持、与主流框架的无缝集成、大型项目管理能力和提升开发体验等多方面优势
TypeScript 是一种静态类型的编程语言,它扩展了 JavaScript,为 Web 开发带来了强大的类型系统、组件化开发支持、与主流框架的无缝集成、大型项目管理能力和提升开发体验等多方面优势。通过明确的类型定义,TypeScript 能够在编码阶段发现潜在错误,提高代码质量;支持组件的清晰定义与复用,增强代码的可维护性;与 React、Vue 等框架结合,提供更佳的开发体验;适用于大型项目,优化代码结构和性能。随着 Web 技术的发展,TypeScript 的应用前景广阔,将继续引领 Web 开发的新趋势。
35 2
|
27天前
|
安全 开发工具 Swift
Swift 是苹果公司开发的现代编程语言,具备高效、安全、简洁的特点,支持类型推断、闭包、泛型等特性,广泛应用于苹果各平台及服务器端开发
Swift 是苹果公司开发的现代编程语言,具备高效、安全、简洁的特点,支持类型推断、闭包、泛型等特性,广泛应用于苹果各平台及服务器端开发。基础语法涵盖变量、常量、数据类型、运算符、控制流等,高级特性包括函数、闭包、类、结构体、协议和泛型。
27 2
|
28天前
|
XML 前端开发 JavaScript
PHP与Ajax在Web开发中的交互技术。PHP作为服务器端脚本语言,处理数据和业务逻辑
本文深入探讨了PHP与Ajax在Web开发中的交互技术。PHP作为服务器端脚本语言,处理数据和业务逻辑;Ajax则通过异步请求实现页面无刷新更新。文中详细介绍了两者的工作原理、数据传输格式选择、具体实现方法及实际应用案例,如实时数据更新、表单验证与提交、动态加载内容等。同时,针对跨域问题、数据安全与性能优化提出了建议。总结指出,PHP与Ajax的结合能显著提升Web应用的效率和用户体验。
40 3
|
1月前
|
前端开发 API 开发者
Python Web开发者必看!AJAX、Fetch API实战技巧,让前后端交互如丝般顺滑!
在Web开发中,前后端的高效交互是提升用户体验的关键。本文通过一个基于Flask框架的博客系统实战案例,详细介绍了如何使用AJAX和Fetch API实现不刷新页面查看评论的功能。从后端路由设置到前端请求处理,全面展示了这两种技术的应用技巧,帮助Python Web开发者提升项目质量和开发效率。
51 1
|
存储 弹性计算 自然语言处理
使用阿里云ECS的开发心得
关于本人使用阿里云ECS的开发心得与使用经验。分享开发提高效率的方法、使用心得。
使用阿里云ECS的开发心得
|
2天前
|
弹性计算 运维 安全
阿里云轻量应用服务器与ECS的区别及选择指南
轻量应用服务器和云服务器ECS(Elastic Compute Service)是两款颇受欢迎的产品。本文将对这两者进行详细的对比,帮助用户更好地理解它们之间的区别,并根据自身需求做出明智的选择。