共计 38289 个字符,预计需要花费 96 分钟才能阅读完成。
有一天,一位女士散步时经过一个工地,看见有三个工人在干活。她问第一个人,“你在做什么?”第一个人有点不高兴,吼道“难道你看不出来我在砌砖吗?”女士对这个答案并不满意,接着问第二个人他在做什么。第二个人回答道,“我正在建造一堵砖墙。”然后,他转向第一个人,说道:“嘿,你砌的砖已经超过墙高了。你得把最后一块砖拿下来。”女士对这个答案还是不满意,她接着问第三个人他在做什么。第三个人抬头看着天空,对她说:“我在建造这个世界上有史以来最大的教堂”。就在他望着天空出神的时候,另外两个人已经开始争吵多出的那块砖。他慢慢转向前两个人,说道:“兄弟们,别管那块砖了。这是一堵内墙, 之后还会被刷上石灰的,没人会注意到这块砖。接着砌下层吧。”
这个故事的寓意在于,当你掌握了整个系统的设计,明白不同的组件是以何种方式组合在一起的(砖块,墙,教堂)时候,你就能够更快地发现并解决问题(多出的砖块)。
但是,这个故事与从头开发一个 Web 服务器有什么关系呢?
在我看来,要成为一名更优秀的程序员,你 必须 更好地理解自己日常使用的软件系统,而这就包括了编程语言、编译器、解释器、数据库与操作系统、Web 服务器和网络开发框架。而要想更好、更深刻地理解这些系统,你 必须 从头重新开发这些系统,一步一个脚印地重来一遍。
孔子曰:不闻不若闻之,闻之不若见之,见之不若知之,知之不若行之。
不闻不若闻之
听别人说怎么做某事
闻之不若见之
看别人怎么做某事
见之不若知之,知之不若行之。
自己亲自做某事
译者注:上面原作者所引用的那段话在国外的翻译是:I hear and I forget, I see and I remember, I do and I understand。外国人普遍认为出自孔子,但在查找这句英文的出处时,查到有篇博文称这句话的中文实际出自荀子的《儒效篇》,经查确实如此。
我希望你读到这里的时候,已经认可了通过重新开发不同软件系统来学习其原理这种方式。
《自己动手开发 Web 服务器》会分为三个部分,将介绍如何从头开发一个简易 Web 服务器。我们这就开始吧。
首先,到底什么是 Web 服务器?
HTTP 请求 / 响应
简而言之,它是在物理服务器上搭建的一个网络连接服务器(networking server),永久地等待客户端发送请求。当服务器收到请求之后,它会生成响应并将其返回至客户端。客户端与服务器之间的通信,是以 HTTP 协议进行的。客户端可以是浏览器,也可以是任何支持 HTTP 协议的软件。
那么,Web 服务器的简单实现形式会是怎样的呢?下面是我对此的理解。示例代码使用 Python 语言实现,不过即使你不懂 Python 语言,你应该也可以从代码和下面的解释中理解相关的概念:
import socket
HOST, PORT ='',8888
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'Serving HTTP on port %s ...'% PORT
whileTrue:
client_connection, client_address = listen_socket.accept()
request = client_connection.recv(1024)
print request
http_response ="""\
HTTP/1.1 200 OK
Hello, World!
"""
client_connection.sendall(http_response)
client_connection.close()
将上面的代码保存为webserver1.py
,或者直接从我的 Github 仓库下载,然后通过命令行运行该文件:
$ python webserver1.py
Serving HTTP on port 8888…
接下来,在浏览器的地址栏输入这个链接:http://localhost:8888/hello,然后按下回车键,你就会看见神奇的一幕。在浏览器中,应该会出现“Hello, World!”这句话:
浏览器返回“Hello World””
是不是很神奇?接下来,我们来分析背后的实现原理。
首先,我们来看你所输入的网络地址。它的名字叫 URL(统一资源定位符(Uniform Resource Locator)),其基本结构如下:
URL 的基本结构
通过 URL,你告诉了浏览器它所需要发现并连接的 Web 服务器地址,以及获取服务器上的页面路径。不过在浏览器发送 HTTP 请求之前,它首先要与目标 Web 服务器建立 TCP 连接。然后,浏览器再通过 TCP 连接发送 HTTP 请求至服务器,并等待服务器返回 HTTP 响应。当浏览器收到响应的时候,就会在页面上显示响应的内容,而在上面的例子中, 浏览器显示的就是“Hello, World!”这句话。
那么,在客户端发送请求、服务器返回响应之前,二者究竟是如何建立起 TCP 连接的呢?要建立起 TCP 连接,服务器和客户端都使用了所谓的套接字(socket)。接下来,我们不直接使用浏览器,而是在命令行使用 telnet
手动模拟浏览器。
在运行 Web 服务器的同一台电脑商,通过命令行开启一次 telnet
会话,将需要连接的主机设置为localhost
,主机的连接端口设置为8888
,然后按回车键:
$ telnet localhost 8888
Trying 127.0.0.1 …
Connected to localhost.
完成这些操作之后,你其实已经与本地运行的 Web 服务器建立了 TCP 连接,随时可以发送和接收 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 请求行包括了 HTTP 方法(这里使用的是 GET
方法,因为我们希望从服务器获取内容),服务器页面路径(/hello
)以及 HTTP 协议的版本。
为了尽量简化,我们目前实现的 Web 服务器并不会解析上面的请求,你完全可以输入一些没有任何意义的代码,也一样可以收到 ”Hello, World!” 响应。
在你输入请求代码并按下回车键之后,客户端就将该请求发送至服务器了,服务器则会解析你发送的请求,并返回相应的 HTTP 响应。
下面这张图显示的是服务器返回至客户端的 HTTP 响应详情:
HTTP 响应解析
我们来分析一下。响应中包含了状态行HTTP/1.1 200 OK
,之后是必须的空行,然后是 HTTP 响应的正文。
响应的状态行 HTTP/1.1 200 OK
中,包含了 HTTP 版本、HTTP 状态码以及与状态码相对应的原因短语(Reason Phrase)。浏览器收到响应之后,会显示响应的正文,这就是为什么你会在浏览器中看到“Hello, World!”这句话。
这就是 Web 服务器基本的工作原理了。简单回顾一下:Web 服务器首先创建一个侦听套接字(listening socket),并开启一个永续循环接收新连接;客户端启动一个与服务器的 TCP 连接,成功建立连接之后,向服务器发送 HTTP 请求,之后服务器返回 HTTP 响应。要建立 TCP 连接,客户端和服务器都使用了套接字。
现在,你已经拥有了一个基本可用的简易 Web 服务器,你可以使用浏览器或其他 HTTP 客户端进行测试。正如上文所展示的,通过 telnet
命令并手动输入 HTTP 请求,你自己也可以成为一个 HTTP 客户端。
下面给大家布置一道思考题:如何在不对服务器代码作任何修改的情况下,通过该服务器运行 Djando 应用、Flask 应用和 Pyramid 应用,同时满足这些不同网络框架的要求?
答案将在《自己动手开发 Web 服务器》系列文章的第二部分揭晓。
更多详情见请继续阅读下一页的精彩内容:http://www.linuxidc.com/Linux/2016-01/126947p2.htm
在《自己动手开发一个 Web 服务器(一)》中,我给大家留了一个问题:如何在不对服务器代码作任何修改的情况下,通过该服务器运行 Djando 应用、Flask 应用和 Pyramid 应用,同时满足这些不同网络框架的要求?读完这篇文章,你就可以回答这个问题了。
以前,你选择的 Python 网络框架将会限制所能够使用的 Web 服务器,反之亦然。如果框架和服务器在设计时就是可以相互匹配的,那你就不会面临这个问题:
服务器与框架是否匹配
但是如果你试图将设计不相匹配的服务器与框架相结合,那么你肯定就会碰到下面这张图所展示的这个问题:
服务器与框架之间冲突
这就意味着,你基本上只能使用能够正常运行的服务器与框架组合,而不能选择你希望使用的服务器或框架。
那么,你怎样确保可以在不修改 Web 服务器代码或网络框架代码的前提下,使用自己选择的服务器,并且匹配多个不同的网络框架呢?为了解决这个问题,就出现了 Python Web 服务器网关接口(Web Server Gateway Interface,WSGI)。
WSGI 接口
WSGI 的出现,让开发者可以将网络框架与 Web 服务器的选择分隔开来,不再相互限制。现在,你可以真正地将不同的 Web 服务器与网络开发框架进行混合搭配,选择满足自己需求的组合。例如,你可以使用 Gunicorn 或 Nginx/uWSGI 或 Waitress 服务器来运行 Django、Flask 或 Pyramid 应用。正是由于服务器和框架均支持 WSGI,才真正得以实现二者之间的自由混合搭配。
所以,WSGI 就是我在上一篇文章中所留问题的答案。你的 Web 服务器必须实现一个服务器端的 WSGI 接口,而目前所有现代 Python 网络框架都已经实现了框架端的 WSGI 接口,这样开发者不需要修改服务器的代码,就可以支持某个网络框架。
Web 服务器和网络框架支持 WSGI 协议,不仅让应用开发者选择符合自己需求的组合,同时也有利于服务器和框架的开发者,因为他们可以将注意力集中在自己擅长的领域,而不是相互倾轧。其他编程语言也拥有类似的接口:例如 Java 的 Servlet API 和 Ruby 的 Rack。
口说无凭,我猜你肯定在想:“无代码无真相!”既然如此,我就在这里给出一个非常简单的 WSGI 服务器实现:
#TestedwithPython2.7.9,Linux&Mac OS X
import socket
importStringIO
import sys
classWSGIServer(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 host name 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
whileTrue:
#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):
self.request_data = request_data =self.client_connection.recv(1024)
#Print formatted request data a la 'curl -v'
print(''.join(
'< {line}\n'.format(line=line)
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 isfor 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']=StringIO.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
returnenv
def start_response(self, status, response_headers, exc_info=None):
#Add necessary server headers
server_headers =[
('Date','Tue, 31 Mar 2015 12: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.
#returnself.finish_response
def finish_response(self, result):
try:
status, response_headers =self.headers_set
response ='HTTP/1.1 {status}\r\n'.format(status=status)
for header in response_headers:
response +='{0}: {1}\r\n'.format(*header)
response +='\r\n'
for data in result:
response += data
#Print formatted response data a la 'curl -v'
print(''.join(
'> {line}\n'.format(line=line)
for line in response.splitlines()
))
self.client_connection.sendall(response)
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('WSGIServer: Serving HTTP on port {port} ...\n'.format(port=PORT))
httpd.serve_forever()
上面的代码比第一部分的服务器实现代码要长的多,但是这些代码实际也不算太长,只有不到 150 行,大家理解起来并不会太困难。上面这个服务器的功能也更多——它可以运行你使用自己喜欢的框架所写出来的网络应用,无论你选择 Pyramid、Flask、Django 或是其他支持 WSGI 协议的框架。
你不信?你可以自己测试一下,看看结果如何。将上述代码保存为webserver2.py
,或者直接从我的 Github 仓库下载。如果你运行该文件时没有提供任何参数,那么程序就会报错并退出。
$ python webserver2.py
Provide a WSGI application object asmodule:callable
上述程序设计的目的,就是运行你开发的网络应用,但是你还需要满足一些它的要求。要运行服务器,你只需要安装 Python 即可。但是要运行使用 Pyramid、Flask 和 Django 等框架开发的网络应用,你还需要先安装这些框架。我们接下来安装这三种框架。我倾向于使用 virtualenv
安装。请按照下面的提示创建并激活一个虚拟环境,然后安装这三个网络框架。
$ [sudo] pip install virtualenv
$ mkdir~/envs
$ virtualenv ~/envs/lsbaws/
$ cd~/envs/lsbaws/
$ ls
bin include lib
$ source bin/activate
(lsbaws) $ pip install pyramid
(lsbaws) $ pip install flask
(lsbaws) $ pip install django
接下来,你需要创建一个网络应用。我们首先创建 Pyramid 应用。将下面的代码保存为 pyramidapp.py
文件,放至 webserver2.py
所在的文件夹中,或者直接从我的 Github 仓库下载该文件:
from pyramid.config importConfigurator
from pyramid.response importResponse
def hello_world(request):
returnResponse(
'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...
在运行 webserver2.py
时,你告诉自己的服务器去加载 pyramidapp
模块中的 app
可调用对象(callable)。你的服务器现在可以接收 HTTP 请求,并将请求中转至你的 Pyramid 应用。应用目前只能处理一个路由(route):/hello。在浏览器的地址栏输入http://localhost:8888/hello
,按下回车键,观察会出现什么情况:
Pyramid 应用运行情况
你还可以在命令行使用 curl
命令,来测试服务器运行情况:
$ curl -v http://localhost:8888/hello
...
接下来我们创建 Flask 应用。重复上面的步骤。
from flask importFlask
from flask importResponse
flask_app =Flask('flaskapp')
@flask_app.route('/hello')
def hello_world():
returnResponse(
'Hello world from Flask!\n',
mimetype='text/plain'
)
app = flask_app.wsgi_app
将上面的代码保存为flaskapp.py
,或者直接从我的 Github 仓库下载文件,并运行:
(lsbaws) $ python webserver2.py flaskapp:app
WSGIServer:Serving HTTP on port 8888...
然后在浏览器地址栏输入http://localhost:8888/hello
,并按下回车:
Flask 应用运行情况
同样,在命令行使用 curl
命令,看看服务器是否会返回 Flask 应用生成的信息:
$ curl -v http://localhost:8888/hello
...
这个服务器是不是也能支持 Django 应用?试一试就知道了!不过接下来的操作更为复杂一些,我建议大家克隆整个仓库,并使用其中的 djangoapp.py
文件。下面的代码将一个名叫 helloworld
的 Django 应用添加至当前的 Python 路径中,然后导入了该项目的 WSGI 应用。
import sys
sys.path.insert(0,'./helloworld')
from helloworld import wsgi
app = wsgi.application
将上面的代码保存为djangoapp.py
,并使用你开发的服务器运行这个 Django 应用。
(lsbaws) $ python webserver2.py djangoapp:app
WSGIServer:Serving HTTP on port 8888...
同样,在浏览器中输入http://localhost:8888/hello
,并按下回车键:
Django 应用的运行情况
接下来,和前面几次一样,你通过命令行使用 curl
命令进行测试,确认了这个 Djando 应用成功处理了你发出的请求:
$ curl -v http://localhost:8888/hello
...
你有没有按照上面的步骤测试?你做到了让服务器支持全部三种框架吗?如果没有,请尽量自己动手操作。阅读代码很重要,但这系列文章的目的在于重新开发,而这意味着你需要自己亲自动手。最好是你自己重新输入所有的代码,并确保代码运行结果符合预期。
经过上面的介绍,你应该已经认识到了 WSGI 的强大之处:它可以让你自由混合搭配 Web 服务器和框架。WSGI 为 Python Web 服务器与 Python 网络框架之间的交互提供了一个极简的接口,而且非常容易在服务器端和框架端实现。下面的代码段分别展示了服务器端和框架端的 WSGI 接口:
def run_application(application):
"""Server code."""
#Thisiswhere 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 ['Hello world!']
run_application(app)
下面给大家解释一下上述代码的工作原理:
- 网络框架提供一个命名为
application
的可调用对象(WSGI 协议并没有指定如何实现这个对象)。 - 服务器每次从 HTTP 客户端接收请求之后,调用
application
。它会向可调用对象传递一个名叫environ
的字典作为参数,其中包含了 WSGI/CGI 的诸多变量,以及一个名为start_response
的可调用对象。 - 框架 / 应用生成 HTTP 状态码以及 HTTP 响应报头(response headers),然后将二者传递至
start_response
,等待服务器保存。此外,框架 / 应用还将返回响应的正文。 - 服务器将状态码、响应报头和响应正文组合成 HTTP 响应,并返回给客户端(这一步并不属于 WSGI 协议)。
下面这张图直观地说明了 WSGI 接口的情况:
WSGI 接口
有一点要提醒大家,当你使用上述框架开发网络应用的时候,你处理的是更高层级的逻辑,并不会直接处理 WSGI 协议相关的要求,但是我很清楚,既然你正在看这篇文章,你一定对框架端的 WSGI 接口很感兴趣。所以,我们接下来在不使用 Pyramid、Flask 或 Djando 框架的前提下,自己开发一个极简的 WSGI 网络应用 / 网络框架,并使用 WSGI 服务器运行该应用:
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
文件,或者直接从我的 Github 仓库下载,然后利用 Web 服务器运行该应用:
(lsbaws) $ python webserver2.py wsgiapp:app
WSGIServer:Serving HTTP on port 8888...
在浏览器中输入下图中的地址,然后按回车键。结果应该是这样的:
简单的 WSGI 应用
你刚刚自己编写了一个极简的 WSGI 网络框架!太不可思议了。
接下来,我们重新分析服务器返回给客户端的对象。下面这张图展示的是你通过 HTTP 客户端调用 Pyramid 应用后,服务器生成的 HTTP 响应:
HTTP 响应对象
上图中的响应与你在第一篇中看到的有些类似,但是也有明显不同之处。举个例子,其中就出现了你之前没有看到过的 4 个 HTTP 报头:Content-Type,Content-Length,Date 和 Server。这些事 Web 服务器返回的响应对象通常都会包含的报头。不过,这四个都不是必须的。报头的目的是传递有关 HTTP 请求 / 响应的额外信息。
既然你已经对 WSGI 接口有了更深的理解,下面这张图对响应对象的内容进行了更详细的解释,说明了每条内容是如何产生的。
HTTP 响应对象 2
到目前为止,我还没有介绍过 environ
字典的具体内容,但简单来说,它是一个必须包含着 WSGI 协议所指定的某些 WSGI 和 CGI 变量。服务器从 HTTP 请求中获取字典所需的值。下面这张图展示的是字典的详细内容:
Environ 字典的详细内容
网络框架通过该字典提供的信息,根据指定的路由和请求方法等参数来决定使用哪个视图(views),从哪里读取请求正文,以及如何输出错误信息。
截至目前,你已经成功创建了自己的支持 WSGI 协议的 Web 服务器,还利用不同的网络框架开发了多个网络应用。另外,你还自己开发了一个极简的网络框架。本文介绍的内容不可谓不丰富。我们接下来回顾一下 WSGI Web 服务器如何处理 HTTP 请求:
- 首先,服务器启动并加载网络框架 / 应用提供的
application
可调用对象 - 然后,服务器读取一个请求信息
- 然后,服务器对请求进行解析
- 然后,服务器使用请求数据创建一个名叫
environ
的字典 - 然后,服务器以
environ
字典和start_response
可调用对象作为参数,调用application
,并获得应用生成的响应正文。 - 然后,服务器根据调用
application
对象后返回的数据,以及start_response
设置的状态码和响应标头,构建一个 HTTP 响应。 - 最后,服务器将 HTTP 响应返回至客户端。
服务器工作原理梳理
以上就是第二部分的所有内容。你现在已经拥有了一个正常运行的 WSGI 服务器,可以支持通过遵守 WSGI 协议的网络框架所写的网络应用。最棒的是,这个服务器可以不需要作任何代码修改,就可以与多个网络框架配合使用。
最后,我再给大家留一道思考题:怎样让服务器一次处理多个请求?
有一天,一位女士散步时经过一个工地,看见有三个工人在干活。她问第一个人,“你在做什么?”第一个人有点不高兴,吼道“难道你看不出来我在砌砖吗?”女士对这个答案并不满意,接着问第二个人他在做什么。第二个人回答道,“我正在建造一堵砖墙。”然后,他转向第一个人,说道:“嘿,你砌的砖已经超过墙高了。你得把最后一块砖拿下来。”女士对这个答案还是不满意,她接着问第三个人他在做什么。第三个人抬头看着天空,对她说:“我在建造这个世界上有史以来最大的教堂”。就在他望着天空出神的时候,另外两个人已经开始争吵多出的那块砖。他慢慢转向前两个人,说道:“兄弟们,别管那块砖了。这是一堵内墙, 之后还会被刷上石灰的,没人会注意到这块砖。接着砌下层吧。”
这个故事的寓意在于,当你掌握了整个系统的设计,明白不同的组件是以何种方式组合在一起的(砖块,墙,教堂)时候,你就能够更快地发现并解决问题(多出的砖块)。
但是,这个故事与从头开发一个 Web 服务器有什么关系呢?
在我看来,要成为一名更优秀的程序员,你 必须 更好地理解自己日常使用的软件系统,而这就包括了编程语言、编译器、解释器、数据库与操作系统、Web 服务器和网络开发框架。而要想更好、更深刻地理解这些系统,你 必须 从头重新开发这些系统,一步一个脚印地重来一遍。
孔子曰:不闻不若闻之,闻之不若见之,见之不若知之,知之不若行之。
不闻不若闻之
听别人说怎么做某事
闻之不若见之
看别人怎么做某事
见之不若知之,知之不若行之。
自己亲自做某事
译者注:上面原作者所引用的那段话在国外的翻译是:I hear and I forget, I see and I remember, I do and I understand。外国人普遍认为出自孔子,但在查找这句英文的出处时,查到有篇博文称这句话的中文实际出自荀子的《儒效篇》,经查确实如此。
我希望你读到这里的时候,已经认可了通过重新开发不同软件系统来学习其原理这种方式。
《自己动手开发 Web 服务器》会分为三个部分,将介绍如何从头开发一个简易 Web 服务器。我们这就开始吧。
首先,到底什么是 Web 服务器?
HTTP 请求 / 响应
简而言之,它是在物理服务器上搭建的一个网络连接服务器(networking server),永久地等待客户端发送请求。当服务器收到请求之后,它会生成响应并将其返回至客户端。客户端与服务器之间的通信,是以 HTTP 协议进行的。客户端可以是浏览器,也可以是任何支持 HTTP 协议的软件。
那么,Web 服务器的简单实现形式会是怎样的呢?下面是我对此的理解。示例代码使用 Python 语言实现,不过即使你不懂 Python 语言,你应该也可以从代码和下面的解释中理解相关的概念:
import socket
HOST, PORT ='',8888
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'Serving HTTP on port %s ...'% PORT
whileTrue:
client_connection, client_address = listen_socket.accept()
request = client_connection.recv(1024)
print request
http_response ="""\
HTTP/1.1 200 OK
Hello, World!
"""
client_connection.sendall(http_response)
client_connection.close()
将上面的代码保存为webserver1.py
,或者直接从我的 Github 仓库下载,然后通过命令行运行该文件:
$ python webserver1.py
Serving HTTP on port 8888…
接下来,在浏览器的地址栏输入这个链接:http://localhost:8888/hello,然后按下回车键,你就会看见神奇的一幕。在浏览器中,应该会出现“Hello, World!”这句话:
浏览器返回“Hello World””
是不是很神奇?接下来,我们来分析背后的实现原理。
首先,我们来看你所输入的网络地址。它的名字叫 URL(统一资源定位符(Uniform Resource Locator)),其基本结构如下:
URL 的基本结构
通过 URL,你告诉了浏览器它所需要发现并连接的 Web 服务器地址,以及获取服务器上的页面路径。不过在浏览器发送 HTTP 请求之前,它首先要与目标 Web 服务器建立 TCP 连接。然后,浏览器再通过 TCP 连接发送 HTTP 请求至服务器,并等待服务器返回 HTTP 响应。当浏览器收到响应的时候,就会在页面上显示响应的内容,而在上面的例子中, 浏览器显示的就是“Hello, World!”这句话。
那么,在客户端发送请求、服务器返回响应之前,二者究竟是如何建立起 TCP 连接的呢?要建立起 TCP 连接,服务器和客户端都使用了所谓的套接字(socket)。接下来,我们不直接使用浏览器,而是在命令行使用 telnet
手动模拟浏览器。
在运行 Web 服务器的同一台电脑商,通过命令行开启一次 telnet
会话,将需要连接的主机设置为localhost
,主机的连接端口设置为8888
,然后按回车键:
$ telnet localhost 8888
Trying 127.0.0.1 …
Connected to localhost.
完成这些操作之后,你其实已经与本地运行的 Web 服务器建立了 TCP 连接,随时可以发送和接收 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 请求行包括了 HTTP 方法(这里使用的是 GET
方法,因为我们希望从服务器获取内容),服务器页面路径(/hello
)以及 HTTP 协议的版本。
为了尽量简化,我们目前实现的 Web 服务器并不会解析上面的请求,你完全可以输入一些没有任何意义的代码,也一样可以收到 ”Hello, World!” 响应。
在你输入请求代码并按下回车键之后,客户端就将该请求发送至服务器了,服务器则会解析你发送的请求,并返回相应的 HTTP 响应。
下面这张图显示的是服务器返回至客户端的 HTTP 响应详情:
HTTP 响应解析
我们来分析一下。响应中包含了状态行HTTP/1.1 200 OK
,之后是必须的空行,然后是 HTTP 响应的正文。
响应的状态行 HTTP/1.1 200 OK
中,包含了 HTTP 版本、HTTP 状态码以及与状态码相对应的原因短语(Reason Phrase)。浏览器收到响应之后,会显示响应的正文,这就是为什么你会在浏览器中看到“Hello, World!”这句话。
这就是 Web 服务器基本的工作原理了。简单回顾一下:Web 服务器首先创建一个侦听套接字(listening socket),并开启一个永续循环接收新连接;客户端启动一个与服务器的 TCP 连接,成功建立连接之后,向服务器发送 HTTP 请求,之后服务器返回 HTTP 响应。要建立 TCP 连接,客户端和服务器都使用了套接字。
现在,你已经拥有了一个基本可用的简易 Web 服务器,你可以使用浏览器或其他 HTTP 客户端进行测试。正如上文所展示的,通过 telnet
命令并手动输入 HTTP 请求,你自己也可以成为一个 HTTP 客户端。
下面给大家布置一道思考题:如何在不对服务器代码作任何修改的情况下,通过该服务器运行 Djando 应用、Flask 应用和 Pyramid 应用,同时满足这些不同网络框架的要求?
答案将在《自己动手开发 Web 服务器》系列文章的第二部分揭晓。
更多详情见请继续阅读下一页的精彩内容:http://www.linuxidc.com/Linux/2016-01/126947p2.htm
在第二部分中,你开发了一个能够处理 HTTPGET 请求的简易 WSGI 服务器。在上一篇的最后,我问了你一个问题:“怎样让服务器一次处理多个请求?”读完本文,你就能够完美地回答这个问题。接下来,请你做好准备,因为本文的内容非常多,节奏也很快。文中的所有代码都可以在 Github 仓库下载。
首先,我们简单回忆一下简易网络服务器是如何实现的,服务器要处理客户端的请求需要哪些条件。你在前面两部分文章中开发的服务器,是一个迭代式服务器(iterative server),还只能一次处理一个客户端请求。只有在处理完当前客户端请求之后,它才能接收新的客户端连接。这样,有些客户端就必须要等待自己的请求被处理了,而对于流量大的服务器来说,等待的时间就会特别长。
客户端逐个等待服务器响应
下面是迭代式服务器 webserver3a.py
的代码:
#####################################################################
#Iterative server - webserver3a.py #
##
#TestedwithPython2.7.9&Python3.4 on Ubuntu14.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))
whileTrue:
client_connection, client_address = listen_socket.accept()
handle_request(client_connection)
client_connection.close()
if __name__ =='__main__':
serve_forever()
如果想确认这个服务器每次只能处理一个客户端的请求,我们对上述代码作简单修改,在向客户端返回响应之后,增加 60 秒的延迟处理时间。这个修改只有一行代码,即告诉服务器在返回响应之后睡眠 60 秒。
让服务器睡眠 60 秒
下面就是修改之后的服务器代码:
#########################################################################
#Iterative server - webserver3b.py #
##
#TestedwithPython2.7.9&Python3.4 on Ubuntu14.04&Mac OS X #
##
#-Server sleeps for60 seconds after sending a response to a client #
#########################################################################
import socket
importtime
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)#sleepand block the process for60 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))
whileTrue:
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
命令应该不会立刻产生任何输出结果,而是处于挂死(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
,它们在这里被称为一个套接字(同理,客户端端点的两个值也是一个套接字)。
服务器创建套接字并开始接受客户端连接的标准流程如下:
服务器创建套接字并开始接受客户端连接的标准流程
-
服务器创建一个 TCP/IP 套接字。通过下面的 Python 语句实现:
listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-
服务器可以设置部分套接字选项(这是可选项,但你会发现上面那行服务器代码就可以确保你重启服务器之后,服务器会继续使用相同的地址)。
listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-
然后,服务器绑定地址。绑定函数为套接字指定一个本地协议地址。调用绑定函数时,你可以单独指定端口号或 IP 地址,也可以同时指定两个参数,甚至不提供任何参数也没问题。
listen_socket.bind(SERVER_ADDRESS)
-
接着,服务器将该套接字变成一个侦听套接字:
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,向在本地运行的服务器发起一个客户端连接,然后查看系统内核为你创建的客户端套接字指定了哪个临时端口(在进行下面的操作之前,请先运行 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。
在开始回答第二部分最后提的问题之前,我需要快速介绍一些其他的重要概念。稍后你就会明白我为什么要这样做。我要介绍的重要概念就是进程(process)和文件描述符(file descriptor)。
什么是进程?进程就是正在执行的程序的一个实例。举个例子,当服务器代码执行的时候,这些代码就被加载至内存中,而这个正在被执行的服务器的实例就叫做进程。系统内核会记录下有关进程的信息——包括进程 ID,以便进行管理。所以,当你运行迭代式服务器 webserver3a.py
或webserver3b.py
时,你也就开启了一个进程。
服务器进程
我们在终端启动 webserver3a.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 系统下,每个用户进程都会有一个父进程(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 中来试验一下。
>>>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
的第二个示例中,我们的服务器在处理完请求后睡眠 60 秒,但是在睡眠期间,我们仍然可以通过 curl
命令与服务器建立连接?当然,curl
命令并没有立刻输出结果,只是出于挂死状态,但是为什么服务器既然没有接受新的连接,客户端也没有立刻被拒绝,而是仍然继续连接至服务器呢?这个问题的答案在于套接字对象的 listen
方法,以及它使用的 BACKLOG
参数。在示例代码中,这个参数的值被我设置为 REQUEST_QUEQUE_SIZE
。BACKLOG
参数决定了内核中外部连接请求的队列大小。当 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
并发服务器,能够同时处理多个客户端请求:
###########################################################################
#Concurrent server - webserver3c.py #
##
#TestedwithPython2.7.9&Python3.4 on Ubuntu14.04&Mac OS X #
##
#-Child process sleeps for60 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
然后输入之前迭代式服务器示例中的两个 curl
命令。现在,即使服务器子进程在处理完一个客户端请求之后会睡眠 60 秒,但是并不会影响其他客户端,因为它们由不同的、完全独立的进程处理。你应该可以立刻看见 curl
命令输出“Hello, World”,然后挂死 60 秒。你可以继续运行更多的 curl
命令,所有的命令都会输出服务器的响应结果——“Hello, World”,不会有任何延迟。你可以试试。
关于 fork()
函数有一点最为重要,就是你调用 fork
一次,但是函数却会返回两次:一次是在父进程里返回,另一次是在子进程中返回。当你 fork
一个进程时,返回给子进程的 PID 是 0,而 fork
返回给父进程的则是子进程的 PID。
fork 函数
我还记得,第一次接触并使用 fork
函数时,自己感到非常不可思议。我觉得这就好像一个魔法。之前还是一个线性的代码,突然一下子克隆了自己,出现了并行运行的相同代码的两个实例。我当时真的觉得这和魔法也差不多了。
当父进程 fork
一个新的子进程时,子进程会得到父进程文件描述符的副本:
当父进程 fork 一个新的子进程时,子进程会得到父进程文件描述符的副本
你可能也注意到了,上面代码中的父进程关闭了客户端连接:
else:# parent
client_connection.close()# close parent copy and loop over
那为什么父进程关闭了套接字之后,子进程却仍然能够从客户端套接字中读取数据呢?答案就在上面的图片里。系统内核根据文件描述符计数(descriptor reference counts)来决定是否关闭套接字。系统只有在描述符计数变为 0 时,才会关闭套接字。当你的服务器创建一个子进程时,子进程就会获得父进程文件描述符的副本,系统内核则会增加这些文件描述符的计数。在一个父进程和一个子进程的情况下,客户端套接字的文件描述符计数为 2。当上面代码中的父进程关闭客户端连接套接字时,只是让套接字的计数减为 1,还不够让系统关闭套接字。子进程同样关闭了父进程侦听套接字的副本,因为子进程不关心要不要接收新的客户端连接,只关心如何处理连接成功的客户端所发出的请求。
listen_socket.close()# close child copy
稍后,我会给大家介绍如果不关闭重复的描述符的后果。
从上面并行服务器的源代码可以看出,服务器父进程现在唯一的作用,就是接受客户端连接,fork
一个新的子进程来处理该客户端连接,然后回到循环的起点,准备接受其他的客户端连接,仅此而已。服务器父进程并不会处理客户端请求,而是由它的子进程来处理。
谈得稍远一点。我们说两个事件是并行时,到底是什么意思?
并行事件
我们说两个事件是并行的,通常指的是二者同时发生。这是简单的定义,但是你应该牢记它的严格定义:
如果你不能分辨出哪个程序会先执行,那么二者就是并行的。
现在又到了回顾目前已经介绍的主要观点和概念。
checkpoint
- Unix 系统中开发并行服务器最简单的方法,就是调用
fork()
函数- 当一个进程
fork
新进程时,它就成了新创建进程的父进程- 在调用
fork
之后,父进程和子进程共用相同的文件描述符- 系统内核通过描述符计数来决定是否关闭文件 / 套接字
- 服务器父进程的角色:它现在所做的只是接收来自客户端的新连接,
fork
一个子进程来处理该客户端的请求,然后回到循环的起点,准备接受新的客户端连接
接下来,我们看看如果不关闭父进程和子进程中的重复套接字描述符,会发生什么情况。下面的并行服务器(webserver3d.py)作了一些修改,确保服务器不关闭重复的:
###########################################################################
#Concurrent server - webserver3d.py #
##
#TestedwithPython2.7.9&Python3.4 on Ubuntu14.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 =[]
whileTrue:
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
命令会没有结束运行呢?原因在于重复的文件描述符(duplicate file descriptor)。当子进程关闭客户端连接时,系统内核会减少客户端套接字的计数,变成了 1。服务器子进程结束了,但是客户端套接字并没有关闭,因为那个套接字的描述符计数并没有变成 0,导致系统没有向客户端发送终止包(termination packet)(用 TCP/IP 的术语来说叫做 FIN),也就是说客户端仍然在线。但是还有另一个问题。如果你一直运行的服务器不去关闭重复的文件描述符,服务器最终就会耗光可用的文件服务器:
文件描述符
按下 Control-C
,关闭webserver3d.py
服务器,然后通过 shell 自带的 ulimit
命令查看服务器进程可以使用的默认资源:
$ ulimit -a
core filesize(blocks,-c)0
data seg size(kbytes,-d) unlimited
scheduling priority (-e)0
filesize(blocks,-f) unlimited
pending signals (-i)3842
max locked memory (kbytes,-l)64
max memory size(kbytes,-m) unlimited
open files (-n)1024
pipe size(512 bytes,-p)8
POSIX message queues (bytes,-q)819200
real-time priority (-r)0
stack size(kbytes,-s)8192
cpu time(seconds,-t) unlimited
max user processes (-u)3842
virtual memory (kbytes,-v) unlimited
file locks (-x) unlimited
从上面的结果中,我们可以看到:在我这台 Ubuntu 电脑上,服务器进程可以使用的文件描述符(打开的文件)最大数量为 1024。
现在,我们来看看如果服务器不关闭重复的文件描述符,服务器会不会耗尽可用的文件描述符。我们在现有的或新开的终端窗口里,将服务器可以使用的最大文件描述符数量设置为 256:
$ ulimit -n 256
在刚刚运行了 $ ulimit -n 256
命令的终端里,我们开启 webserver3d.py
服务器:
$ python webserver3d.py
然后通过下面的 client3.py
客户端来测试服务器。
#####################################################################
#Test client - client3.py #
##
#TestedwithPython2.7.9&Python3.4 on Ubuntu14.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 auxw |grep-i python |grep-v grep
vagrant 90990.01.2318046256 pts/0 S+16:330:00 python webserver3d.py
vagrant 91020.00.000 pts/0 Z+16:330:00[python]<defunct>
我们发现,第二行中显示的这个进程的 PID 为 9102,状态是 Z +,而进程的名称叫做<defunct>
。这就是我们要找的僵尸进程。僵尸进程的问题在于你无法杀死它们。
僵尸进程无法被杀死
即使你试图通过 $ kill -9
命令杀死僵尸进程,它们还是会存活下来。你可以试试看。
到底什么是僵尸进程,服务器又为什么会创建这些进程?僵尸进程其实是已经结束了的进���,但是它的父进程并没有等待进程结束,所以没有接收到进程结束的状态信息。当子进程在父进程之前退出,系统就会将子进程变成一个僵尸进程,保留原子进程的部分信息,方便父进程之后获取。系统所保留的信息通常包括进程 ID、进程结束状态和进程的资源使用情况。好吧,这样说僵尸进程也有自己存在的理由,但是如果服务器不处理好这些僵尸进程,系统就会堵塞。我们来看看是否如此。首先,停止正在运行的服务器,然后在新终端窗口中,使用 ulimit
命令将最大用户进程设置为 400(还要确保将打开文件数量限制设置到一个较高的值,这里我们设置为 500)。
$ ulimit -u 400
$ ulimit -n 500
然后在同一个窗口中启动 webserver3d.py
服务器:
$ python webserver3d.py
在新终端窗口中,启动客户端client3.py
,让客户端创建 500 个服务器并行连接:
$ 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
文件:
###########################################################################
#Concurrent server - webserver3e.py #
##
#TestedwithPython2.7.9&Python3.4 on Ubuntu14.04&Mac OS X #
###########################################################################
import os
import signal
import socket
importtime
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)
whileTrue:
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
函数调用报错了。
accept 函数调用失败
子进程退出时,父进程被阻塞在 accept
函数调用的地方,但是子进程的退出导致了 SIGCHLD
事件,这也激活了信号处理函数。信号函数执行完毕之后,就导致了 accept
系统函数调用被中断:
accept 调用被中断
别担心,这是个非常容易解决的问题。你只需要重新调用 accept
即可。下面我们再修改一下服务器代码(webserver3f.py),就可以解决这个问题:
###########################################################################
#Concurrent server - webserver3f.py #
##
#TestedwithPython2.7.9&Python3.4 on Ubuntu14.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)
whileTrue:
try:
client_connection, client_address = listen_socket.accept()
exceptIOErroras 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 webserver3f.py
通过 curl
命令向服务器发送一个请求:
$ curl http://localhost:8888/hello
看到了吗?没有再报错了。现在,我们来确认下服务器没有再产生僵尸进程。只需要运行 ps
命令,你就会发现没有 Python 进程的状态是 Z + 了。太棒了!没有僵尸进程捣乱真是太好了。
checkpoint
- 如果你 fork 一个子进程,却不等待进程结束,该进程就会变成僵尸进程。
- 使用
SIGCHLD
时间处理函数来异步等待进程结束,获取其结束状态。- 使用事件处理函数时,你需要牢记系统函数调用可能会被中断,要做好这类情况发生得准备。
好了,目前一切正常。没有其他问题了,对吗?呃,基本上是了。再次运行 webserver3f.py
,然后通过client3.py
创建 128 个并行连接:
$ python client3.py --max-clients 128
现在再次运行 ps
命令:
$ ps auxw |grep-i python |grep-v grep
噢,糟糕!僵尸进程又出现了!
僵尸进程又出现了
这次又是哪里出了问题?当你运行 128 个并行客户端,建立 128 个连接时,服务器的子进程处理完请求,几乎是同一时间退出的,这就触发了一大波的 SIGCHLD
信号发送至父进程。但问题是这些信号并没有进入队列,所以有几个信号漏网,没有被服务器处理,这就导致出现了几个僵尸进程。
部分信号没有被处理,导致出现僵尸进程
这个问题的解决方法,就是在 SIGCHLD
事件处理函数使用 waitpid
,而不是wait
,再调用waitpid
时增加 WNOHANG
选项,确保所有退出的子进程都会被处理。下面就是修改后的代码,webserver3g.py:
###########################################################################
#Concurrent server - webserver3g.py #
##
#TestedwithPython2.7.9&Python3.4 on Ubuntu14.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):
whileTrue:
try:
pid, status = os.waitpid(
-1,#Waitfor any child process
os.WNOHANG #Donot block andreturn EWOULDBLOCK error
)
exceptOSError:
return
if pid ==0:#nomore 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)
whileTrue:
try:
client_connection, client_address = listen_socket.accept()
exceptIOErroras 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
现在请确认不会再出现僵尸进程了。
不会再出现僵尸进程了
恭喜大家!现在已经自己开发了一个简易的并发服务器,这个代码可以作为你以后开发生产级别的网络服务器的基础。
最后给大家留一个练习题,把第二部分中的 WSGI 修改为并发服务器。最终的代码可以在这里查看。不过请你在自己实现了之后再查看。
接下来该怎么办?借用乔希·比林斯(19 世纪著名幽默大师)的一句话:
要像一张邮票,坚持一件事情直到你到达目的地。
坚持就是胜利
本文永久更新链接地址:http://www.linuxidc.com/Linux/2016-01/126947.htm