Python有很多Web框架:Django、Flask、Tornodo、web.py。我们可以基于这些框架来开发我们的网站。这些框架其实是给我们封装了很多底层的实现。比如WSGI、模板、映射等功能。为了在使用这些框架时对其有更深入的了解,所以简单翻译了这篇由老外写的”自己动手实现WebServer”。原文链接在文章最后。
为什么要从0到1搭建Web服务器
想要成为一个更优秀的开发者,你必须对每天在使用的底层软件系统有更好的了解,包括编程语言、编译器和解释器、数据库和操作系统、Web服务器和框架等。而且,为了更好、更深入地理解这些系统,你必须从头开始,一步一个脚印地重新构建它们。
子曰:闻之我也野,视之我也饶,行之我也明。
孔子想要表达的是:
- 听到的,容易忘记
- 看到的,会依稀记得
- 身体力行后,才能真正的理解
所以通过重新构建不同的软件系统来了解它们的工作原理是个非常不错的方法。
什么是Web服务器?
简而言之,它是一个位于物理服务器上的网络服务器(服务器上的服务器?!),它等待客户端发送请求。当它收到一个请求后,会生成响应并将其发送回客户端。客户端和服务器之间的通信使用HTTP协议进行。客户端可以是您的浏览器或任何其他使用HTTP协议的软件。
实际流程要不上面介绍的复杂多,下图包含了TCP三次握手。HTTP消息交互流程等:
继续往下,这些数据是如何组装和解析的呢。HTTP协议就是基于TCP/IP协议模型来传输消息的,这就需要了解TCP/IP协议结构。HTTP协议就位于应用层。
有了上面的层次结构,再来看下数据的组装以及协议,在每个协议层都解析出各自的头以及信息。并把剩下的消息递交给上层继续解析。这就好比我们现在的包裹快递,在每个投递站打上各自的投递信息。最终达到的时候我们就可以查到一个完整的包裹传递路线。
更具体的话,还有ARP查询过程,DNS查询过程等等,就不在这里介绍了。前面介绍了整个HTTP协议报文的传输以及结构,下面就来看下如何实现具体的实例。一个非常简单的Web服务器实现是什么样的?以下示例是使用Python写的,从下面的代码和解释中理解基本的概念:
# Python 3.7+ import socket HOST, PORT = '', 8888 # 初始化了HOST地址和端口,如果不指明地址的话默认本地地址127.0.0.1 listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listen_socket.bind((HOST, PORT)) listen_socket.listen(1) print(f'Serving HTTP on port {PORT}...') while True: client_connection, client_address = listen_socket.accept() request_data = client_connection.recv(1024) print(request_data.decode('utf-8')) http_response = b"""\ HTTP/1.1 200 OK Hello, World! """ client_connection.sendall(http_response) client_connection.close()
将上面的代码保存为webserver1.py并在命令行上运行:
$ python webserver1.py Serving HTTP on port 8888…
现在,在Web浏览器的地址栏中键入以下URL:http://localhost:8888/hello,按Enter键,你应该在浏览器中看到”Hello,World”,如下所示:
现在讨论一下它实际上是如何工作的。首先让我们从你输入的Web地址开始。它被叫做URL,下面是它的基本结构:
这就是你告诉你的浏览器它需要用来查找和连接的Web服务器地址以及需要显示给你的位于服务器上的页面(路径)。在你的浏览器发送一个HTTP请求前,它首先需要与Web服务器建立一条TCP连接。然后再通过这个TCP连接发送一个HTTP请求到服务器,然后等待服务器发送回一个HTTP响应。当你的浏览器接收到这个响应的时候,它就会显示它。在这里它将显示”Hello, World!”。
那么,在客户端发送请求、服务器返回响应之前,二者究竟是如何建立起TCP连接的呢?要建立起TCP连接,服务器和客户端都使用了所谓的套接字(socket)我们来看下socket的使用过程。整个过程可以参考下图:
下面讲解下Socket模块功能。
Socket类型
套接字格式:socket(family, type[, protocal])使用给定的套接族,套接字类型,协议编号(默认为0)来创建套接字
socket类型 | 描述 |
socket.AF_UNIX | 用于同一台机器上的进程通信(既本机通信) |
socket.AF_INET | 用于服务器与服务器之间的网络通信 |
socket.AF_INET6 | 基于IPV6方式的服务器与服务器之间的网络通信 |
socket.SOCK_STREAM | 基于TCP的流式socket通信 |
socket.SOCK_DGRAM | 基于UDP的数据报式socket通信 |
socket.SOCK_RAW | 原始套接字,普通的套接字无法处理ICMP、IGMP等网络报文,而SOCK_RAW可以;其次SOCK_RAW也可以处理特殊的IPV4报文;此外,利用原始套接字,可以通过IP_HDRINCL套接字选项由用户构造IP头 |
可靠的连续数据包服务 |
创建 TCP Socket:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
创建 UDP Socket:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
Socket 函数
- TCP 发送数据时,已建立好 TCP 链接,所以不需要指定地址,而 UDP 是面向无连接的,每次发送都需要指定发送给谁。
- 服务器与客户端不能直接发送列表,元素,字典等带有数据类型的格式,发送的内容必须是字符串数据。
服务器端 Socket 函数
Socket 函数 | 描述 |
s.bind(address) | 将套接字绑定到地址,在 AF_INET 下,以 tuple(host,port) 的方式传入,如 s.bind((host,port)) |
s.listen(backlog) | 开始监听 TCP 传入连接,backlog 指定在拒绝链接前,操作系统可以挂起的最大连接数,该值最少为 1,大部分应用程序设为 5 就够用了 |
s.accept() | 接受 TCP 链接并返回(conn,address),其中 conn 是新的套接字对象,可以用来接收和发送数据,address 是链接客户端的地址。 |
客户端 Socket 函数
Socket 函数 | 描述 |
s.connect(address) | 链接到 address 处的套接字,一般 address 的格式为 tuple(host,port),如果链接出错,则返回 socket.error 错误 |
s.connect_ex(address) | 功能与 s.connect(address) 相同,但成功返回 0,失败返回 errno 的值 |
公共 Socket 函数
Socket 函数 | 描述 |
s.recv(bufsize[,flag]) | 接受 TCP 套接字的数据,数据以字符串形式返回,buffsize 指定要接受的最大数据量,flag 提供有关消息的其他信息,通常可以忽略 |
s.send(string[,flag]) | 发送 TCP 数据,将字符串中的数据发送到链接的套接字,返回值是要发送的字节数量,该数量可能小于 string 的字节大小 |
s.sendall(string[,flag]) | 完整发送 TCP 数据,将字符串中的数据发送到链接的套接字,但在返回之前尝试发送所有数据。成功返回 None,失败则抛出异常 |
s.recvfrom(bufsize[,flag]) | 接受 UDP 套接字的数据 u,与 recv() 类似,但返回值是 tuple(data,address)。其中 data 是包含接受数据的字符串,address 是发送数据的套接字地址 |
s.sendto(string[,flag],address) | 发送 UDP 数据,将数据发送到套接字,address 形式为 tuple(ipaddr,port),指定远程地址发送,返回值是发送的字节数 |
s.close() | 关闭套接字 |
s.getpeername() | 返回套接字的远程地址,返回值通常是一个 tuple(ipaddr,port) |
s.getsockname() | 返回套接字自己的地址,返回值通常是一个 tuple(ipaddr,port) |
s.setsockopt(level,optname,value) | 设置给定套接字选项的值 |
s.getsockopt(level,optname[,buflen]) | 返回套接字选项的值 |
s.settimeout(timeout) | 设置套接字操作的超时时间,timeout 是一个浮点数,单位是秒,值为 None 则表示永远不会超时。一般超时期应在刚创建套接字时设置,因为他们可能用于连接的操作,如 s.connect() |
s.gettimeout() | 返回当前超时值,单位是秒,如果没有设置超时则返回 None |
s.fileno() | 返回套接字的文件描述 |
s.setblocking(flag) | 如果 flag 为 0,则将套接字设置为非阻塞模式,否则将套接字设置为阻塞模式(默认值)。非阻塞模式下,如果调用 recv() 没有发现任何数据,或 send() 调用无法立即发送数据,那么将引起 socket.error 异常。 |
s.makefile() | 创建一个与该套接字相关的文件 |
为了代替浏览器直连,你可以通过在命令行上使用 telnet 命令的方式来手动模拟浏览器的行为。
在你运行 Web 服务器的电脑上打开一个 telnet 会话,可以通过在命令行上输入 telent 并指定连接到 localhost 这个主机和 8888 这个端口,然后按下回车键:
$ telnet localhost 8888 Trying 127.0.0.1… Connected to localhost.
此刻,你已经与运行在你的本地机器上的准备发送和接收 HTTP 消息的服务器建立了一条 TCP 连接。在下面的图片中你将看到一套标准的程序,服务器必须遵守这套程序以便能够接受新的 TCP 连接。
在相同的 telnet 会话中输入 GET /hello HTTP/1.1 然后按下回车键:
$ telnet localhost 8888 Trying 127.0.0.1… Connected to localhost. GET /hello HTTP/1.1 HTTP/1.1 200 OK Hello, World!
你刚手动模拟了你的浏览器!你发送了一个 HTTP 请求并收到了一个 HTTP 响应。下面是一个基本的 HTTP 请求的结构:
HTTP请求包含了一个表示HTTP方法的行(GET),路径(/hello)表示了服务器上一个我们需要的“页面”,以及协议版本(HTTP/1.1)。
为了简单起见,我们的Web服务器在这里完全忽略了上面提到的请求行。你可以用任何垃圾数据代替“GET /hello HTTP/1.1”,你依然可以得到一个内容为“Hello, World!”的响应。
一旦你输入完请求行并按下回车键,客户端就会把请求发送到服务器,服务器读取请求行,打印出来,并返回合适的HTTP响应。
下面是服务器发送回你的客户端(在这里是telnet)的HTTP响应:
响应包括一个状态行 HTTP/1.1 200 OK, 接下来是一个空行,然后是HTTP响应的body。response状态行HTTP/1.1 200 OK包括了HTTP版本,HTTP状态码以及HTTP状态码原因词组OK。当浏览器获取到响应时,它将显示响应的body部分,这就是为什么你能在你的浏览器中看到“Hello, World!”的原因。
这就是一个Web服务器如何工作的基本模型了。总结一下:Web服务器创建一个socket监听并开始在一个循环里接受新的连接。客户端启动一个TCP连接,成功建立连接之后客户端发送一个HTTP请求到服务器,然后服务器响应一个展示给用户的HTTP response。客户端和服务器都使用socket来建立TCP连接。
如何让Web应用运行在Web服务器中?
现在你已经有一个非常基础的Web服务器了,有个问题要问你:“如何在你这个新鲜出炉的Web服务器上运行一个Django应用,Flask应用,以及Pyramid应用,并且不需要做任何的改动就可以适应这些不同的Web框架?”
过去,选择的Python Web框架会限制你对可用Web服务器的选择,反之亦然。如果选择的框架和服务器被设计的可以一起工作的话,那就皆大欢喜了:
但是当你尝试将服务器和并非一起工作的框架组合在一起时,你可能会遇到以下问题(也许你曾经遇到过):
一般来说你必须使用能够一起工作的组件而不仅仅是你想使用的组件。那么,你如何确保你能够在你的Web服务器上运行多个Web框架,并且不需要修改Web服务器或Web框架的现有的代码呢?解决这个问题的答案就是Python Web Server Gateway Interface(简称WSGI)。
WSGI允许开发者自由选择Web框架和Web服务器。现在你可以任意混搭不同的Web服务器和Web框架,并选择一个你需要的合适的组合。比如,你可以用Gunicorn或Nginx/uWSGI或Waitress运行Django, Flask, 或Pyramid。,感谢那些服务器和框架对WSGI的支持,实现了真正的随意混搭:
因此,WSGI就是问题的答案。你的Web服务器必须实现WSGI接口的服务器端部分,所有的现代Python Web框架都已经实现了WSGI接口的框架端部分,这部分允许你不需要修改你的服务器代码去适应某个特定的框架就可以使用这些框架。其他语言也有类似的接口:例如,Java有Servlet API,Ruby有Rack。一起来看看下面这个非常简约的WSGI服务器实现吧:
# Tested with Python 3.7+ (Mac OS X) import io import socket import sys class WSGIServer(object): address_family = socket.AF_INET socket_type = socket.SOCK_STREAM request_queue_size = 1 def __init__(self, server_address): # Create a listening socket self.listen_socket = listen_socket = socket.socket( self.address_family, self.socket_type ) # Allow to reuse the same address listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # Bind listen_socket.bind(server_address) # Activate listen_socket.listen(self.request_queue_size) # Get server hostname and port host, port = self.listen_socket.getsockname()[:2] self.server_name = socket.getfqdn(host) self.server_port = port # Return headers set by Web framework/Web application self.headers_set = [] def set_app(self, application): self.application = application def serve_forever(self): listen_socket = self.listen_socket while True: # New client connection self.client_connection, client_address = listen_socket.accept() # Handle one request and close the client connection. Then # loop over to wait for another client connection self.handle_one_request() def handle_one_request(self): request_data = self.client_connection.recv(1024) self.request_data = request_data = request_data.decode('utf-8') # Print formatted request data a la 'curl -v' print(''.join( f'<{line}\n' for line in request_data.splitlines() )) self.parse_request(request_data) # Construct environment dictionary using request data env = self.get_environ() # It's time to call our application callable and get # back a result that will become HTTP response body result = self.application(env, self.start_response) # Construct a response and send it back to the client self.finish_response(result) def parse_request(self, text): request_line = text.splitlines()[0] request_line = request_line.rstrip('\r\n') # Break down the request line into components (self.request_method, # GET self.path, # /hello self.request_version # HTTP/1.1 ) = request_line.split() def get_environ(self): env = {} # The following code snippet does not follow PEP8 conventions # but it's formatted the way it is for demonstration purposes # to emphasize the required variables and their values # # Required WSGI variables env['wsgi.version'] = (1, 0) env['wsgi.url_scheme'] = 'http' env['wsgi.input'] = io.StringIO(self.request_data) env['wsgi.errors'] = sys.stderr env['wsgi.multithread'] = False env['wsgi.multiprocess'] = False env['wsgi.run_once'] = False # Required CGI variables env['REQUEST_METHOD'] = self.request_method # GET env['PATH_INFO'] = self.path # /hello env['SERVER_NAME'] = self.server_name # localhost env['SERVER_PORT'] = str(self.server_port) # 8888 return env def start_response(self, status, response_headers, exc_info=None): # Add necessary server headers server_headers = [ ('Date', 'Mon, 15 Jul 2019 5:54:48 GMT'), ('Server', 'WSGIServer 0.2'), ] self.headers_set = [status, response_headers + server_headers] # To adhere to WSGI specification the start_response must return # a 'write' callable. We simplicity's sake we'll ignore that detail # for now. # return self.finish_response def finish_response(self, result): try: status, response_headers = self.headers_set response = f'HTTP/1.1 {status}\r\n' for header in response_headers: response += '{0}: {1}\r\n'.format(*header) response += '\r\n' for data in result: response += data.decode('utf-8') # Print formatted response data a la 'curl -v' print(''.join( f'>{line}\n' for line in response.splitlines() )) response_bytes = response.encode() self.client_connection.sendall(response_bytes) finally: self.client_connection.close() SERVER_ADDRESS = (HOST, PORT) = '', 8888 def make_server(server_address, application): server = WSGIServer(server_address) server.set_app(application) return server if __name__ == '__main__': if len(sys.argv) < 2: sys.exit('Provide a WSGI application object as module:callable') app_path = sys.argv[1] module, application = app_path.split(':') module = __import__(module) application = getattr(module, application) httpd = make_server(SERVER_ADDRESS, application) print(f'WSGIServer: Serving HTTP on port {PORT}...\n') httpd.serve_forever()
上面的代码能运行用上面所见的 Web 框架(Pyramid, Flask, Django, 或其他的 Python WSGI 框架)所写的基础 Web 应用。不信?动手试一下吧。把上面的代码保存为 webserver2.py。如果你不带任何参数就运行这个程序的话,它会报错,然后退出。
$ python webserver2.py Provide a WSGI application object as module:callable
要运行服务器,你唯一需要安装的就是 Python(确切地说是 Python 3.7+)。但要运行使用 Pyramid,Flask 和 Django 编写的应用程序,你需要先安装这些框架。我首选的方法是使用 venv(默认情况下在 Python 3.3 及更高版本中可用)。只需按照以下步骤创建并激活虚拟环境,然后安装这三个 Web 框架。
$ python3 -m venv lsbaws $ ls lsbaws bin include lib pyvenv.cfg $ source lsbaws/bin/activate (lsbaws) $ pip install -U pip (lsbaws) $ pip install pyramid (lsbaws) $ pip install flask (lsbaws) $ pip install Django
到这一步的时候你需要创建一个 Web 应用。让我们先用 Pyramid 开始吧。把下面的代码保存为 pyramidapp.py 并放到你之前所保存的 webserver2.py 文件所在目录(即:把 pyramidapp.py 放在 webserver2.py 所在目录):
from pyramid.config import Configurator from pyramid.response import Response def hello_world(request): return Response( 'Hello world from Pyramid!\n', content_type='text/plain', ) config = Configurator() config.add_route('hello', '/hello') config.add_view(hello_world, route_name='hello') app = config.make_wsgi_app()
现在,你已准备好使用自己的 Web 服务器为 Pyramid 应用程序提供服务:
(lsbaws) $ python webserver2.py pyramidapp:app WSGIServer: Serving HTTP on port 8888...
你刚刚告诉你的服务器从 python 模块 ‘pyramidapp’ 加载可调用的 ‘app’。你的服务器现在已准备好接收请求并将它们转发到你的 Pyramid 应用程序。应用程序现在只处理一个路径:/hello 路由。在浏览器中键入 http://localhost:8888/hello 地址,按 Enter 键,然后观察结果:
现在轮到 Flask。让我们按照相同的步骤来操作。
from flask import Flask from flask import Response flask_app = Flask('flaskapp') @flask_app.route('/hello') def hello_world(): return Response( 'Hello world from Flask!\n', mimetype='text/plain' ) app = flask_app.wsgi_app
把上面的代码保存为 flaskapp.py,然后用以下方式运行服务器:
(lsbaws) $ python webserver2.py flaskapp:app WSGIServer: Serving HTTP on port 8888...
在你的浏览器中输入 http://localhost:8888/hello 然后按下回车键:
这个服务器能处理 Django 应用吗?试一下就知道了!这次涉及的东西有点复杂,我建议你克隆这个仓库然后使用 GitHub 仓库中的 djangoapp.py 文件。下面的源码主要是添加 Django hello world 项目(预先使用 Django 的 django-admin.py startproject 命令生成的项目)到当前 Python 路径然后导入项目中的 WSGI 应用。
import sys sys.path.insert(0, './helloworld') from helloworld import wsgi app = wsgi.application
把上面的代码保存为 djangoapp.py 然后用你的 Web 服务器运行这个 Django 应用:
(lsbaws) $ python webserver2.py djangoapp:app WSGIServer: Serving HTTP on port 8888...
输入如下地址并回车:
你已经熟悉 WSGI 的威力了:它允许你混搭你的 Web 服务器和 Web 框架。WSGI 规定了 Python Web 服务器和 Python Web 框架之间的一些接口。它非常的简单,不管是在服务器还是框架端都非常容易实现。下面的代码片段展示了服务器和框架端的接口:
def run_application(application): """Server code.""" # This is where an application/framework stores # an HTTP status and HTTP response headers for the server # to transmit to the client headers_set = [] # Environment dictionary with WSGI/CGI variables environ = {} def start_response(status, response_headers, exc_info=None): headers_set[:] = [status, response_headers] # Server invokes the 'application' callable and gets back the # response body result = application(environ, start_response) # Server builds an HTTP response and transmits it to the client ... def app(environ, start_response): """A barebones WSGI app.""" start_response('200 OK', [('Content-Type', 'text/plain')]) return [b'Hello world!'] run_application(app)
它的工作原理是这样的:
- 框架提供了一个 application 可调用对象(WSGI 规范没有规定它应该如何被实现)
- 每当收到来自 HTTP 客户端的请求的时候,服务器就调用这个 application 可调用对象。它把一个包含 WSGI/CGI 变量的字典 environ 和一个 start_response 可调用对象作为参数传递给了 application 可调用对象。
- 框架/应用生成一个 HTTP 状态信息和 HTTP 响应头信息,并把它们传递给了 start_response 可调用对象,让服务器把它们存起来。框架/应用也返回了一个响应 body 信息。
- 服务器把状态信息,响应头信息以及响应 body 信息合并为一个 HTTP 响应,然后把它传输给客户端(这一步不是规范的一部分,但是它是流程中的下一个逻辑步骤,为了清晰可见我把它列在了这里
以下是这个接口的可视化展示:
到目前为止,你已经看过 Pyramid、Flask 和 Django Web 应用程序,并且你已经看到了实现服务器端 WSGI 规范的服务器代码。你甚至已经看到了不使用任何框架的非正式 WSGI 应用程序代码片段。让我们创建一个简约的 WSGI Web 应用程序/Web 框架,而不使用 Pyramid、Flask 或 Django,并在你的服务器上运行它:
def app(environ, start_response): """A barebones WSGI application. This is a starting point for your own Web framework :) """ status = '200 OK' response_headers = [('Content-Type', 'text/plain')] start_response(status, response_headers) return ['Hello world from a simple WSGI application!\n']
再一次的,把上面的代码保存为 wsgiapp.py,然后用你的 Web 服务器像下面这样运行这个应用:
(lsbaws)$ python webserver2.py wsgiapp:app WSGIServer: Serving HTTP on port 8888...
输入如下地址并按下回车键。你应该会看到这样的结果:
现在,让我们回到服务器都给客户端传输了什么东西。下面是当你使用 HTTP 客户端调用你的 Pyramind 应用时,服务器生成的 HTTP 响应:
这个响应有一些你在前面看到过的东西,但是它也有一些新东西。比如说,它有四个你之前还没见过的 HTTP headers:Content-Type, Content-Length, Date 以及 Server. 这些包含在响应里的头信息是一个 Web 服务器应该要生成的信息。虽然它们中没有一个是严格要求必须提供的。这些头信息的目的是传输关于 HTTP 请求/响应的附加信息。下面是同一个 HTTP 响应部分是如何产生的更详细的信息:
environ 是一个 Python 字典,它必须包含某些由 WSGI 规范所规定的 WSGI 和 CGI 变量。解析完请求信息后,服务器从 HTTP 请求中得到这个字典所需的一些值。这个字典看起来像下面这样:
Web 框架使用来自这个字典里的信息来决定那个 view 可以被用来服务,基于获得的路由,请求方法等信息, 决定可以从哪里读取请求的 body 信息以及哪里可以用来写入错误信息,如果有的话。
目前为止,你已经创建了你自己的 WSGI Web 服务器,你也用不同的 Web 框架编写过 Web 应用了。同时,你也顺便创建过极其简陋的 Web 应用/Web 框架。让我们来重述一下为了服务一个针对 WSGI 应用的请求信息,你的 WSGI Web 框架需要做的事情:
- 首先,服务器启动并载入一个由你的 Web 框架/应用所定义的 application 可调用对象
- 然后,服务器读取一个请求
- 然后,服务器解析这个请求
- 然后,服务器用这个请求数据构建了一个 environ 字典
- 然后,服务器以 environ 字典和一个 start_response 可调用对象作为参数来调用 application 对象,并获得一个返回的响应 body。
- 然后,服务器用通过调用 application 对象获得的 body 数据以及通过 start_reponse 可调用对象设置的状态信息和响应头信息一起构建了一个 HTTP 响应。
- 最后,服务器把 HTTP 响应传输回客户端
你现在有了一个可以工作的 WSGI 服务器,它能够服务那些用 WSGI 兼容的 Web 框架开发的基础的 Web 应用。最棒的是不需要修改任何的服务器代码就可以与多个 Web 框架一起使用。
如何让 Web 服务器同时处理多个请求?
这里有另一个问题需要你思考,如何让你的服务器能够在同一时刻处理多个请求?
首先,让我们来回顾一下一个非常基础的Web服务器看起来是啥样的,以及这个服务器需要做些什么才能服务来自客户端的请求。你创建的服务器是个一次只能处理一个客户端请求的循环服务器。在它处理完正在处理的客户端请求之前,它是无法接受新的连接的。一些客户端可能会不高兴,因为它们必须得排队等待,对于那些非常繁忙的服务器,这个等待可能会是个非常漫长的过程。
循环服务器 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()
为了观察你的服务器一次只能处理一个客户端请求的现象,服务器代码需要做一点修改,在发送响应信息给客户端后的地方增加了一个60秒的延时。这一行的更改是为了告诉服务器进程需要休息60秒。
下面是包含休息代码的服务器代码 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
如果你在60秒内做完了这些操作的话,第二个 curl 应该不会立马输出任何信息,只是会阻塞在那里。服务器应该也没有在它的标准输出中答应新的请求 body 信息。在你等了足够长的时间后(大于60秒)你应该会看到第一个 curl 结束以及第二个 curl 在屏幕上打印了”Hello, World!”,然后阻塞60秒,之后再结束。
它的工作方式是服务器完成对第一个 curl 客户机请求的服务,然后在休眠60秒后才开始处理第二个请求。这一切都是按顺序进行的,或一步一步的循环,在这里是一次只能处理一个客户端请求。
让我们抽出一点时间来说一下关于客户端和服务器之间的通信方面的东西。为了让两个程序能够在网络进行中进行通信,它们需要使用套接字(socket)。你已经在前面中学习果 socket 了。但是什么 socket 呢?
socket 是一个通信端点的抽象概念,它允许你的程序通过文件描述与另一个程序进行通信。在这篇文章中,我会特别讲述在 Linux/MacOSX 上的 TCP/IP socket。需要理解一个重要的概念,那就是 TCP socket 对。一个 TCP 连接的 socket 对是一个4元组,这个元组标识了一个 TCP 连接的两个端点:本地 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在这里被归为一个套接字(客户端端点有相同的应用)。
一个服务器通常通过创建一个套接字,然后开始接受来自客户端的请求,它的常规顺序如下:
- 服务器创建一个 TCP/IP socket。这个用的是下面的 Python 语句来实现的:
listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- 服务器可能会设置一些 socket 选项(这是可选的,但是你能在上面的服务器代码中看到它,只是为了在你决定杀死或重启服务器的时候能够立即就可以一遍又一遍的重复使用相同的地址):
listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
- 然后,服务器绑定这个地址. bind 函数在 socket 上分配一个本地协议地址。对于 TCP 则是,调用 bind 让你指定一个端口号,一个 IP 地址,这两个都要或都不需要指定:
listen_socket.bind(SERVER_ADDRESS)
- 然后,服务器把这个 socket 设定为一个监听 socket
listen_socket.listen(REQUEST_QUEUE_SIZE)
这个 listen 方法只需要在服务器端进行调用。它告诉内核,它应该接受目标为这个 socket 的接入连接请求。
这样做以后,服务器就开始在一个循环内一次接受来自一个客户端的连接。当有一个可用的连接的时候,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 地址和本地端口号。这个本地端口叫做临时端口。
服务器上的端口标识了一个知名(well-know)服务,客户端连接的端口叫做一个知名端口(比如,HTTP 的80端口,SSH 的22端口)。起一个 Python shell 然后发起一个到你本地运行的服务器的客户端连接,然后查看为你创建的套接字分配了什么样的临时端口(在尝试下面的例子前需要先启动服务器 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。
进程
什么是进程?一个进程只是一个正在执行的程序的实例。比如说,当服务器代码被执行的时候,它被加载到内存里,然后这个正在执行的程序的实例就叫做进程。内核记录了有关这个进程的一大串的信息(比如进程的 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 命令告诉你你确实只是运行了一个 Python 进程 webserver3b。当一个进程被创建的时候,内核给它分配了一个进程 ID, PID。在 UNIX 中,每个用户进程同时也有一个父进程,这个进程有它自己的进程 ID 叫做父进程 ID 或缩写为 PPID。一般情况下,我假定你运行了一个 BASH shell,然后当你启用服务器的时候,一个新的进程被创建并且有一个 PID,同时它的父 PID 其实就是那个 BASH shell 的 PID。
再次启动你的 Python shell,这将创建一个新的进程,然后通过调用 os.getpid() 和 os.getppid() 来获取这个 Python shell 进程的 PID 和父 PID(你的 BASH shell 的 PID)。然后在另一个终端窗口中运行 ps 和 grep 命令来获取 PPID(父进程 ID,在我的这里是 3148)。在下面的截图中你可以看到一个父子关系的例子,它展示的是在我的 Mac OS X 机器上子 Python shell 进程与父 BASH shell 进程之间的父子关系:
文件描述符
另一个非常重要并且需要了解的概念是文件描述符。那么,什么是文件描述符呢?一个文件描述符是一个正整数,当一个进程打开一个存在的文件,创建一个新文件或创建一个新的套接字的时候,内核返回一个正整数给进程,这个正整数就是文件描述符。你可能听说过,在 UNIX 中一切皆文件。内核通过文件描述符来索引一个进程打开的文件。当你需要读或写一个文件时,你需要用文件描述符来标记它。Python 给了你一些更高级别的对象用来处理文件(和套接字),你不需要使用文件描述符来标识一个文件。下面展示了在 Unix 中文件和套接字是如何被标识的:通过它们的整数文件描述符。
默认情况下,UNIX shell 给一个进程的标准输出分配的文件描述符是 0,标准输入的文件描述符是 1,标准错误的文件描述符是 2。
正如我前面提到的,尽管 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 中处理文件和套接字的时候,你通常需要使用一个高级别的 file/socket 对象。但是在这里你可能需要多次直接使用文件描述符。下面的例子展示了你可以通过调用一个 write 系统并把文件描述符正数作为一个参数的方式来写入一个字符串到标准输出:
>>> import sys >>> import os >>> res = os.write(sys.stdout.fileno(), 'hello\n') Hello
这里是个非常有意思的地方,你的套接字也有一个分配给它的文件描述符。再说一遍,当你在 Python 中创建一个套接字的时候,你得到了一个对象和一个正整数,你也可以通过直接访问我之前提过的 fileno() 方法的方式得到这个套接字的整数文件描述符。
>>> import socket >>> sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) >>> sock.fileno() 3
在第二个例子中的循环服务器 webserver3b.py 中,当服务器进程正在休眠 60 秒的时候,你仍然能够用第二个 curl 命令连接服务器吗?当然可以,只是 curl 将不会立即输出任何信息,它只是会阻塞在那里。但是,为什么当时服务器并没有在 accept 接受一个连接,而客户端却没有立即被拒绝连接,却依然能够连接服务器?答案是套接字对象的 listen 方法以及它的 BACKLOG 参数,在代码里面我调用的是 REQUEST_QUEUE_SIZE。BACKLOG 参数指定了内核中接入连接请求的队列大小。当服务器 webserver3b.py 正在休眠的时候,你可以用第二个 curl 命令连接服务器,是因为内核中用于该服务器套接字的接入连接请求队列还有足够的可用空间。
虽然加大 BACKLOG 参数的值并不能把你的服务器变成一个可以一次处理多个客户端请求的服务器,但是,对于非常繁忙的服务器来说,有一个足够大的 backlog 参数是非常重要的,这样 accept 调用就不用等待有新的连接被建立,可以直接从队列中拿取新的连接,然后开始没有延时的处理这个客户端请求。
让我们快速回顾一下你目前说学到的东西:
- 循环服务器
- 服务器 socket 创建顺序(socket, bind, listen, accept)
- 客户端链接创建顺序(socket, connect)
- 套接字对
- 套接字
- 临时端口和知名端口
- 进程
- 进程 ID(PID),父进程 ID(PPID),以及父子关系。
- 文件描述符
- listen socket 方法的 BACKLOG 参数的含义
现在,已经准备好回答“如何让你的服务器在同一时刻处理多个请求?”或者“如何写一个并发服务器?”
在 Unix 下写一个并发服务器的最简单的方法是使用 fork() 系统调用。下面是一个新的并发服务器 webserver3c.py 的代码,这个服务器能够同时处理多个客户端请求(同上一个服务器 webserver3b.py 一样,每个子进程都会休息 60 秒)。
########################################################################### # 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是如何工作前,先试一下吧,看看服务器是否真的能够同时处理多个客户端发送过来的请求,在命令行下用以下命令启动服务器:
$ python webserver3c.py
然后尝试你之前试过的那两条同样的命令,现在尽管服务器的子进程在服务了一个客户端请求后会休眠60秒,但是仍然不影响其它的客户端,因为它们是由完全没有依赖的不同进程服务的。你可以看到你的curl命令立即输出了“Hello, World!”,然后卡住了60秒。你可以继续执行n条curl命令,所有的这些命令都将立即输出服务器的响应“Hello, World”。
理解fork()最重要的一点是,你调用了一次fork但是它返回了两次:一次在父进程,一次在子进程。当你fork一个新进程时,返回给子进程的进程ID是0。当fork在父进程中返回时,它返回的是子进程的PID。
当一个父进程fork一个新的子进程时,子进程获得了一份父进程的文件描述符的拷贝:
你可能已经看到了,在上面代码中父进程关闭了客户端连接:
else: # parent client_connection.close() # close parent copy and loop over
如果父进程已经关掉了这个socket那么子进程要怎么做才能从客户端socet中读取到数据呢?答案就在上图中。内核使用描述符引用计数来决定是否需要关闭一个socket。只有当某个socket的描述符引用计数变成0的时候才会关闭这个socket。当你的服务器创建一个子进程的时候,子进程获得了父进程的文件描述符拷贝,内核将这些描述符的引用计数也相应的增加了。在有一个父进程和一个子进程的情况下,关联者客户端socket的描述符引用计数就会是2,当父进程想在上面的代码中那样关闭了客户端socket链接的时候,引用计数就会减少变成1,但是仍然还没达到让内核关闭这个socket的条件。子进程也需要关闭来自父进程监听的socket拷贝,因为子进程不关心接收新的客户端请求,它只关心处理来自已建立连接的客户端连接:
listen_socket.close() # close child copy
正如你在这个并发服务器源码中说发现的那样,服务器的父进程现在只有一个角色,那就是接收一个新的客户端连接,fork一个新的子进程用来处理这个请求,然后循环以便接收另一个客户端的连接,没有其他多余的事情了。服务器的父进程不会处理客户端请求——它的子进程会去处理。
当我们说两个事件是并发执行的时候,通常我们的意思是,它们是同时发生的。简短的定义当然非常好,但是你也应该记住复杂的定义:如果你没法通过观察程序来知道哪个是先执行的,那么这两个事件就是并发执行的。
- 在Unix下写并发服务器的最简单的方法是调用系统内的fork()方法
- 当一个进程fork了一个新的进程的时候,它就变成了那个新fork的子进程的父进程。
- 在调用fork后父子进程共享相同的文件描述符。
- 内核使用描述符引用计数来决定是否需要关闭文件/socket
- 服务器的父进程现在只有一个角色,那就是接收一个新的客户端连接,fork一个新的子进程用来处理这个请求,然后循环以便接收另一个客户端的连接。
让我们来看一下,如果你没有在父子进程中关闭socket描述符副本会发生什么。下面是一个修改版的并发服务器,它没有关闭描述符副本,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打印了来自并发服务器的响应,但是它并没有立即退出而是卡住那儿了。发生什么事情了?服务器不再休息60了:它的子进程还在处理客户端请求,关闭客户端连接然后退出,但是客户端curl仍然没有退出。
那么,为什么curl没有退出呢?原因就是文件描述符副本。当子进程关闭客户端连接的时候,内核减少了那个客户端socket的引用计数,此时计数变成了1。虽然服务器的子进程退出了,但是客户端socket并没有被内核关闭,因为此时该socket描述符的引用计数还不是0,结果终止包(在TCP/IP中被叫做FIN)并没有被发送给客户端,可以说是客户端就会一直在线。这里还有另外一个问题。如果你的长时间运行的服务器没有关闭文件描述符副本的话,它最终将用尽所有可用的文件描述符:
使用ctrl+c停止你的服务器webserver3d.py,然后通过在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
正如你在上面看到的,服务器进程在我的Ubuntu上最大可打开的文件描述符(open files)数目是1024。
现在我们来看一下如果你的服务器没有关闭描述符副本,它是怎么样用尽可用的文件描述符的。在一个已有的或新开的终端窗口中,设置最大可打开的文件描述符数目为256:
$ ulimit -n 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的方式来杀死僵尸进程也没有用,它们仍然能够活下来。你可以自己试试看。
僵尸进程究竟是什么呢?为什么我们的服务器会创建它们?僵尸进程是指一个已经终止了的进程,但是它的父进程并没有等待它,也没有收到它的终止状态。当一个子进程在它的父进程之前退出时,内核会把这个子进程转换为僵尸进程,同时存储该进程的一下信息方便它的父进程之后来恢复它。存储的信息通常包括进程ID、进程终止状态以及进程的资源使用情况。好的,因此僵尸进程服务一个特殊的目的,但是如果你的服务器没有照顾好这些僵尸进程的话,你的系统将会变得拥堵不堪。让我们来看看这是怎么发生的。首先,停止你正在运行的服务器,然后在一个新的终端窗口中使用ulimit命令设置max user processes为400(确保设置的open files足够高,也可以设置为500):
$ ulimit -u 400 $ ulimit -n 500
在你刚才输入$ ulimit -u 400命令的窗口启动webserver3d.py服务器:
$ python webserver3d.py
在另一个新的终端窗口中,启动client3.py并告诉它同时创建500个连接:
$ python client3.py --max-clients=500
很快,你的服务器就会崩溃并抛出OSError: Resource temporarily unavailable的异常信息,当它尝试创建一个新的子进程,但是却没法创建成功,因为它已经超出了允许创建的最大子进程数量。下面是我机子上关于异常信息的截图:
如你所见,如果你的长久运行的服务器不好好照看好僵尸进程的话,它们就会导致出现问题。我将会简短的讨论一下服务器应该如何处理僵尸进程问题。
让我们来回顾一下你目前已经了解到的知识点:
- 如果你没有关闭描述符副本,客户端将不会退出,因为客户端连接还没有被关闭。
- 如果你没有关闭描述符副本,你那长时间运行的服务器最终将耗尽所有可用的文件描述符(max open files)。
- 当你fork一个子进程然后退出,同时父进程没有等待(wait)子进程完成退出操作,父进程就收集不到子进程的退出状态,子进程最终就会变成一个僵尸进程。
- 僵尸是需要吃东西的。我咱们这里,它们吃内存。如果不管这些僵尸进程的话,你的服务器将最终耗尽所有可用的进程(max user processes)
- 你无法kill一个僵尸进程,你需要等(wait)它完成退出操作。
那么,你应该如何处理僵尸进程呢?你需要修改你的服务器代码 wait 等待所有的僵尸进程直到得到它们的退出状态。你可以通过修改你的服务器去调用一个 wait 系统调用的方式来达到这个目的。不幸的是,理想跟现实是有差距的,因为如果你调用 wait 然后又没有已经退出的子进程的话,调用 wait 将阻塞你的服务器,这就阻止你的服务器处理新的客户端连接请求。难道就没有其他选项了吗?有的,一种解决办法就是联合使用 signal handler 和 wait 系统调用。
下面展示了是它如何工作。当一个子进程退出的时候,内核发送了一个 SIGCHLD 信号。父进程可以设置一个用于异步接收 SIGCHLD 事件的信号处理器,并且这个处理可以 wait 子进程以便收集它的终止状态,这样就可以阻止僵尸进程的发生了。
随便说一句,一个异步事件意味着父进程事先并不知道那个事件会发生。
修改你的服务器代码,设置一个 SIGCHLD 时间处理器,在这个时间处理器中 wait 子进程终止。可用的 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 调用中被阻塞,SIGCHLD 事件又激活了信号处理程序,并且当信号处理程序完成 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+ 的 Python 僵尸进程了(不再有 <default> 进程)。
- 如果你 fork 了一个子进程,但是却没有 wait 它,它就会变成一个僵尸进程。
- 使用 SIGCHLD 事件处理器来异步 wait 终止的子进程以便收集它的终止状态,
- 当使用事件处理器的时候,你需要考虑到系统可能会中断,这样的话你就需求为这个场景做些准备。
好了,目前来看一起都很棒。再试试 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
现在验证一下已经不再有僵尸进程了。
现在你已经有了你自己的简单的并发服务器,这些代码可以作为你将来开发产品级Web服务器的基础。
“如果你只学习方法,你将束缚于你的方法。但是,如果你学会了原理,你就可以发明你自己的方法。”——Ralph Waldo Emerson
下面是一些我选出的覆盖本文大部分知识的书籍。
- UNIX网络编程卷1:套接字联网API(第3版)
- UNIX环境高级编程(第3版)
- Linux/UNIX系统编程手册
- TCP/IP详解卷1:协议(第2版)
- The Little Book of SEMAPHORES (2nd Edition): The Ins and Outs of Concurrency Control and Common Mistakes也可以从作者的网站上获取免费版本。
参考链接: