有天一个女人出去散步,她经过建筑工地时看到三个人正在干活。她上去问第一个人,你在干什么呢?第一个人觉得这问题很恼人,厉声道,你看不到我在砌砖头吗?不甚满意的女人又问第二个人他在做什么。第二个人答道,我在砌一道砖墙。然后他看了下第一个人,喊道,嘿,你超过墙的长度了,把最后一块砖拿下来。女人还是不满意这个答案,他问第三个人。这个人呢,他一边看着天一边跟她说,我在建这世上从未有过的大教堂。在他抬头望天的时候,另两个人在为砖头的对错争吵不休。这人转向那两人说,伙计们,别为那一块砖当心了。这是个内墙,它会被粉刷没人能看到砖头的。把它放到另一层去吧。
这个故事的寓意是,当你知道整个系统,了解不同组件如何相互配合(砖,墙壁,教堂),你能快速找到和快速解决问题(砖)。
它对你从头开始建web服务器有什么启示呢?
我相信要成为好的开发者,你必须对日常使用的软件底层系统有更好的理解,这包括编程语言,编译器和解释器,数据库和操作系统,web服务器和web框架。而为了能更好更深的理解这些系统,你必须从头开始重建他们,从一砖一瓦开始。
老夫子有言曰:
我听见了,我就忘了;
我看见了,我就记得了;
我做过了,我就理解了。
希望你同意这点,我们重新建构软件系统是学习他们怎样运作的好方法。
在这个分为三部分的系列中,我将展示给你怎样搭建你自己的web服务器。我们开始吧。
简而言之,这是一个运行在物理服务器上的网络服务器,它等待客户端发送的请求。当它收到一个请求,它会生成一个回复并传回到客户端。一个客户端和服务器的通信时通过HTTP协议实现。客户端可以是你的浏览器或任何其他应用HTTP的软件。
一个简单的web服务器是什么样呢?这是我给出的答案。这个例子是用Python的,但即使你不懂Python也能通过下面的代码和解释理解这些概念。
将上面代码保存为’websever1.py’,或者直接在GitHub上下载,然后想下面那样在命令行中运行
$ python webserver1.py
Serving HTTP on port 8888 …
现在在浏览器地址栏输入http://localhost:8888/hello,按Enter键,奇迹就发生率。你应该能在浏览器看到“Hello,World”,如图所示:
看到了吧。让我们来看看它到底是怎么做到的。
从你键入的网址开始。它是一个URL下面是他的基本结构:
这是你告诉浏览器找寻web服务器和连接的地址,也是你要获取的服务器上的页面(路径)。在你的浏览器发送HTTP请求前,它需要和web服务器建立一个TCP连接。然后它通过TCP连接发出HTTP请求,接着等待服务器返回一个HTTP响应。当浏览器收到响应后显示出来,在这个例子中它显示为“Hello World!”
再来详细看看客户端和服务器怎样在HTTP请求响应之前建立TCP连接的。要做到这个,他们都用到了socket。不要直接用浏览器,你用telnet命令行来手动模拟浏览器。
在同一台电脑上通过telnet会话运行web服务器,指定localhost和8888端口,按下Enter:
$ telnet localhost 8888
Trying 127.0.0.1 …
Connected to localhost.
这样你就已经跟服务器建立了TCP连接,它运行在本地主机准备好发送和接收HTTP消息。下面的图片中你可以看到服务器必须经过一个标准的程序才可能接受一个新的TCP连接。
在同一telnet会话中键入GET /hello HTTP/1.1,按下Enter:
$ 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指向服务器的一个“页面”和协议版本。
为了简单的找到我们的web服务器,这个例子完全无视上面的命令行。你可以用任何没意义的东西替换“GET /hello HTTP/1.1”,还是会得到“Hello, World!”
一旦你输入了请求而且按下了Enter,客户端就像服务器发出了请求,服务器读取请求,打印它然后做出适当的响应。
这是HTTP响应,服务器传送到你的客户端的过程(这里是telnet):
我们来剖析它。响应由一个状态行 HTTP/1.1 200 OK, 接着一个空白行,然后是HTTP响应主体。
状态行 HTTP/1.1 200 OK, 由HTTP版本,HTTP状态代码和HTTP状态代码指示短语 OK 组成。当浏览器收到响应,它显示出响应主体,这就是为什么你在浏览器中看到“Hello, World!”
这就是一个web服务器运行的基本模型。总结起来:web服务器创建一个监听socket持续地接受新的连接。客户端发起一个TCP连接,然后成功建立连接,客户端发出一个HTTP请求给服务器,服务器用HTTP响应来做回复,最后呈现给用户。建立TCP连接的过程中客户端和服务器都使用了 socket。
现在你有了一个基本的服务器,可以在浏览器和其他HTTP客户端去测试。如你所见,你也可以做个人肉HTTP客户端,用telnet同时手动键入hTTP请求就行。
那么问题来了:你怎么在你刚建立的web服务器上运行一个Django应用,Flask应用和Pyramid应用,如何不做任何改变而适应不同的web架构呢?
在以前,你选择 Python web 架构会受制于可用的web服务器,反之亦然。如果架构和服务器可以协同工作,那你就走运了:
但你有可能面对(或者曾有过)下面的问题,当要把一个服务器和一个架构结合起来是发现他们不是被设计成协同工作的:
基本上你只能用可以一起运行的而非你想要使用的。
那么,你怎么可以不修改服务器和架构代码而确保可以在多个架构下运行web服务器呢?答案就是 Python Web Server Gateway Interface (或简称 WSGI,读作“wizgy”)。
WSGI允许开发者将选择web框架和web服务器分开。现在你可以混合匹配web服务器和web框架,选择一个适合你需要的配对。比如,你可以在Gunicorn 或者 Nginx/uWSGI 或者 Waitress上运行 Django, Flask, 或 Pyramid。真正的混合匹配,得益于WSGI同时支持服务器和架构:
WSGI是第一篇和这篇开头又重复问道问题的答案。你的web服务器必须具备
WSGI接口,所有的现代Python Web框架都已具备WSGI接口,它让你不对代码作修改就能使服务器和特点的web框架协同工作。
现在你知道WSGI由web服务器支持,而web框架允许你选择适合自己的配对,但它同样对于服务器和框架开发者提供便利使他们可以专注于自己偏爱的领域和专长而不至于相互牵制。其他语言也有类似接口:java有Servlet API,Ruby 有 Rack。
说这么多了,你肯定在喊,给我看代码!好吧,看看这个极简的WSGI服务器实现:
这比第一篇的代码长的多,但也足够短(只有150行)来让你理解而避免在细节里越陷越深。上面的服务器可以做更多——可以运行你钟爱web框架所写基本的web应用,Pyramid, Flask, Django, 或其他 Python WSGI 框架.
不相信我?你自己试试看。保存上面的代码为webserver2.py或者直接在Github下载。如果你不传入任何参数它会提醒然后推出。
$ python webserver2.py
Provide a WSGI application object as module:callable
它需要为web应用服务,这样才会有意思。运行服务器你唯一要做的就是按照python。但是要运行 Pyramid, Flask, 和 Django 写的应用你得先按照这些框架。我们索性三个都安装好了。我偏爱用virtualenv。只要按照下面的步骤创建一个虚拟环境然后按照这三个web框架。
$ [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
这时你要建立一个web应用了。我们从Pyramid开始。在webserver2.py所在的文件夹保存下面代码为pyramidapp.py,也可以直接在Githhub下载:
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
你的服务器已经为你的 Pyramid 应用准备好了:
(lsbaws) $ python webserver2.py pyramidapp:app
WSGIServer: Serving HTTP on port 8888 …
你告诉服务器载入一个来自python ‘pyramidapp’模块的‘app’,然后做好准备接收请求并传给你的 Pyramid 应用。这个应用只处理一个路径: /hello 路径。在浏览器中输入地址 http://localhost:8888/hello,按下Enter,就看到结果:
你也可以用’curl‘在命令行中测试服务器:
$ curl -v http://localhost:8888/hello
…
接着是 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 或者从 GitHub下载,然后运行服务器:
(lsbaws) $ python webserver2.py flaskapp:app
WSGIServer: Serving HTTP on port 8888 …
在浏览器中输入地址 http://localhost:8888/hello,按下Enter:
继续,用’curl‘看看服务器返回 Flask 应用生成的消息:
这个服务器能处理 Django 应用吗?试试看!这会更复杂一点,我建议克隆整个repo,用djangoapp.py, 它是GitHub repository的一部分。这里给出代码,只是添加Django ’helloworld‘工程到当前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 …
输入地址,按下Enter:
同样的就像你以及试过的几次,再用命令行试试看,确认这个 Django 应用也是可以处理你的请求:
$ curl -v http://localhost:8888/hello
…
你试了吗?你确定这个服务器和三个框架都能工作吗?要是没有,快去做吧。看文章很重要,但这个系列是重建,就是说你得身体力行。去试试,我会等你的,别担心。你必须自己验证,最好能自己写所用的东西以确保它能达到预期。
好了,你已经体验了WSGI的强大:它让你混合匹配web服务器和架构。WSGI为python web服务器和框架提供一个最小的接口。它非常简单且容易应用到服务器和框架两端。下面的代码段是服务器和框架端的接口:
它这样工作:
- 这个框架提供一个可调用的’application’(WSGI规范没有规定如何应实现)
- 服务器从HTTP客户端接收请求,并调用’application’。它把包含 WSGI/CGI 变量的字典‘environ’和‘start_response’ 调用作为参数传给 ‘application’ 。
- 框架/应用生成一个HTTP状态和HTTP响应头,将他们传入‘start_response’ 让服务器来存储。框架/应用同时返回响应体。
- 服务器将状态,响应头和响应提结合成HTTP响应并且传送到客户端(这一步不是标准中的部分,但却是合乎逻辑的下一步,为明了起见我加在这里)
这里是界面的可视化表示:
到此为止,你看到了Pyramid, Flask, 和 Django Web 应用,看到了服务器端实现WSGI规范的代码。你看到了没用任何框架的WSGI应用代码。
问题是你在用这些框架写web应用时是在一个更高的层级并不直接接触WSGI,但我知道你也好奇框架端的WSGI接口,当然也因为你在看这篇文章。所以,我们来建一个极简的WSGI web应用/框架,不用Pyramid, Flask, 或Django,在你的服务器上运行:
保存上面代码为wsgiapp.py,或者在GitHub下载,想下面这样运行:
输入地址,按下Enter,你就看到结果:
回去看服务器传了什么给客户端 。这里是你用HTTP客户端调用你的Pyramid应用时服务器生成的HTTP响应:
这个响应有些部分和第一篇看到的相似,但也有些新的东西。它有四个你之前没看到过的HTTP头:内容类型,内容长度,日期和服务器。这些头饰一个web服务器响应通常应该有的。即便没有一个是必须的,它没的作用是发送HTTP请求/响应的附加信息。
你对WSGI接口有了更多的了解,这里还有些信息关于这条HTTP响应是那部分产生的:
我还没说过‘environ’字典的任何东西,它基本上就是必须包含由WSGI规范规定的明确WSGI和CGI参数的python字典。服务器解析请求后从请求中取出参数放入字典。这是字典中包含内容的样子:
web框架用字典中的信息决定通过特点路径的呈现,响应方式去,哪里去读取响应体和哪里去写入错误,如果有的话。
你创建了自己的WSGI web服务器,你用不同框架写了自己的web应用。你也创建了自己基本的web应用/框架。这是一个 heck 之旅。来回顾一下你的WSGI服务器对应用都要做些什么:
- 首先,服务器启动然后载入你的web框架/应用提供的‘application’调用
- 然后,服务器读取请求
- 然后,服务器解析它
- 然后,它根据请求数据创建一个‘environ’ 字典
- 然后,它用‘environ’ 字典调用‘application’,‘start_response’ 做参数同时得到一个响应体
- 然后, 通过‘start_response’ 用‘application’返回的数据和状态及响应头创建一个HTTP响应。
最后,服务器将HTTP响应传回到客户端。
必须发明时我们学的最好——Piaget
在第二篇你建了一个极简的WSGI服务器,可以出来基本的HTTP GET请求。结束时我问了个问题,你怎么保证你的服务器能同时处理多个请求?在这篇文章中你会找到答案。所以,系好安全带,换高档位,你将会超高速行驶。准备好你的Linux,Mac OS X(或其他*nix系统)和python。这篇文章的所有代码都在GitHub。
首先让我们回忆一个基本的web服务器是什么样子,它需要对客户端的请求做什么。在第一篇和第二篇中你建是一个的迭代服务器,一次处理一个客户端请求。它不能接受新的连接直到处理完当前客户端请求。一些客户端可能会不高兴,因为他们必须排队等待,而一些忙碌的服务器这队就太长了。
这是迭代服务器的代码webserver3a.py:
仔细看你的服务器一次只处理一个请求,稍微修改这个服务器在给客户端发送响应加上一个60秒的延时。这个改变只有一行来告诉服务器进程休眠60秒。
这是可休眠服务器的代码 webserver3b.py:
启动服务器:
$ python webserver3b.py
现在打开一个新的终端窗口然后运行curl命令。你应该会立即看到“Hello, World!”被打印在屏幕上:
$ curl http://localhost:8888/hello
Hello, World!
不要等待打卡第二个终端运行同样curl命令:
$ curl http://localhost:8888/hello
你要是在60秒内做完了,那第二个curl不会立即有任何显示而只停在那里。服务器也不会打印出一个新请求的标准输出。在我的Mac上是这个样子的(在右下方高亮的窗口显示了第二个curl命令挂起,等待连接被服务器接受):
等待时间足够长之后(多余60秒)你应该看到第一个curl终止,第二个curl的窗口打印出“Hello, World!”,然后挂起60秒,然后终止:
它的工作方式是这样的,服务器处理完第一个curl客户端请求后休眠60秒然后开始处理第二个请求。这些都是按顺序一步步来,或者在这个例子中一个时刻,一个客户端请求。
我们讨论一下客户端和服务器之间的通信。要让两个程序通过网络彼此通讯,他们需要用到socket。你在第一篇和第二篇都看到了socket,但socket是什么呢?
socket是一个通信终端的抽象,它允许你的程序通过描述文件与另一个程序通信。在这篇文章中我会谈到Linux/Mac OS X上典型的TCP/IP socket一个重要的概念是TCP socket对。
TCP连接的socket对是有4个值的tuple用来标识TCP连接的两个端点:本地IP地址,本地端口,外部IP地址,外部端口。socket对唯一标识网络上的每个TCP连接。这两个成对的值标识各自端点,一个IP地址和一个端口号,通常被称为一个socket。
tuple {10.10.10.2:49152, 12.12.12.3:8888} 是客户端上一个唯一标识两个TCP连接终端的socket, {12.12.12.3:8888, 10.10.10.2:49152} 是客户端上一个唯一标识相同的两个TCP连接终端的socket。IP地址12.12.12.3和端口8888在TCP连接中用来识别服务器端点(同样适用于客户端)。
标准的服务器创建一个socket然后接受客户端连接的流程如下图所示:
服务器创建一个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)
3、然后,服务器绑定地址。bind函数给socket分配一个本地地址。在TCP中,调用bind允许你指定端口号,IP地址,要么两个要么就没有。
listen_socket.bind(SERVER_ADDRESS)
接着服务器让这个socket成为监听socket
listen_socket.listen(REQUEST_QUEUE_SIZE)
listen方法只供服务器调用。它告诉内核应该接受给这个socket传入的连接请求
这些完成后,服务器开始逐个接受客户端连接。当一个连接可用accept返回要连接的客户端socket。然后服务器读从客户端socket取请求数据,打印出响应标准输出然后给客户端socket传回消息。然后服务器关闭客户端连接,准备接受一个新的客户端连接。
下图就是在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)
创建socket之后,客户端需要连接服务器。这是通过connect调用来完成的:
sock.connect((‘localhost’, 8888))
客户端只需提供服务器的远程地址或是主机名和远程端口号来连接。
你可能已经注意到客户端没有调用bind和accept。其原因是客户端不关心本地IP地址和端口号。客户端调用connect时内核中的TCP/IP socket会自动分配本地IP地址和端口号。本地端口被称为临时端口,一个短命的端口。
客户端连接用以获取已知服务的服务器端口成为已知端口(例如80是HTTP,2
2是SSH)。打开python shell在本地主机开启一个客户端连接,看看内核给你的socket分配了哪个临时端口(先启动webserver3a.py 或者 webserver3b.py):
>>> import socket
>>> sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
>>> sock.connect((‘localhost’, 8888))
>>> host, port = sock.getsockname[:2]
>>> host, port
(‘127.0.0.1’, 60589)
上面的例子内核分配给socket的临时端口是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,那么启动服务器一个进程被创建同时一个PID被设定,同时一个PPID在BASH中被设定。
你自己试试看它是怎么做的。再次打开python shell,它就产生了一个新进程,然后用ow.getpid和os.getppid这恋歌系统调用查看PID和PPID。接着在另一个终端窗口运行ps命令同时grep搜索这个PPID(我这里是3148).在下面的截屏中你看到一个关于我Moc OS X系统上BASH进程和python shell进程的父子关系:
另一个必须知道的的概念是文件描述符。那什么是文件描述符呢?是当一个进程打开现有的文件,创建一个新文件,或者当它创建一个新的socket时,内核返回给它的一个非负整数。你应该知道在UNIX中所有东西都是文件。内核通过文件描述符指向一个打开的文件。当你需要读写文件是就用文件描述符来识别。python给你跟高级别的对象来处理文件,你不需要直接用文件描述符来识别文件,但在底层,UNIX中文件和socket的识别是用他们的整数文件描述符。
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中处理文件和socket时,你通常会使用一个高层次的文件/ Socket对象,但也有可能,你需要直接使用文件描述符。这里给出一个例子,你用write系统调用给标准输出写入一个字符串,它将文件描述符作为一个参数:
这是一个有趣的部分——你不会在感到惊讶,因为你已经知道在UNIX中所有的都是文件——你的socket也有一个与其关联的文件描述符。继续,我前面说到那样创建一个socket你得到一个对象和一个非负整数,你总是可以直接通过fileno方法获取文件描述符。
还有一件事情:你有没有注意到注意到在第二个迭代服务器webserver3b.py的例子中,服务器在60秒休眠中你还可以通过第二个curl命令连接到服务器。当然curl没有立即有任何输出,它挂起了,但是为什么服务器没有在那时就接受连接,也没有立即拒绝客户端,而是允许它连接到服务器呢?答案是socket对象的listen方法和它的BACKLOG 参数,在代码中是REQUEST_QUEUE_SIZE。BACKLOG 参数决定内核处理进来连接请求队列的长度。服务器 webserver3b.py休眠时,第二个curl能够连接到服务器是因为内核有足够的空间给进来的连接请求。
增加BACKLOG 参数不能让你的服务器理解神奇到可以同时处理多个客户端请求。要繁忙的服务器不必等待继而接受一个新的连接,而是立即从消息队列中抓取新的连接同时没有延迟的开始一个客户端响应进程,一个相当大的BACKLOG参数是非常重要的。
你已经了解够多了。来快速回顾一下你目前为止所学的(或者复习一下你的基础)。
- 迭代服务器
- 服务器socket创建过程(socket,bind,listen,accept)
- 客户端socket创建过程(socket,connect)
- socket对
- socket
- 临时端口和已知端口
- 进程
- 进程ID(PID),父进程ID(PPID),和父子关系
- 文件描述符
- 监听socket的BSCKLOG参数的意义
现在我准备回答第二篇的问题:你怎么保证你的服务器能同时处理多个请求?或者换个方式,如何编写并发服务器?
在UNIX下最简单的方法是用一个fork系统调用。
这是你新的兵法服务器的代码[webserver3c.py](https://github.com/rspivak/lsbaws/blob/master/part3/webserver3c.py),它可以同时处理多个客户端请求(跟在迭代服务器webserver3b.py一样,每个子进程休眠60秒):
在讨论fork怎么工作前,你自己看看这个服务器却是同时处理多个请求,而不像前连个。在命令行中用下面命令:
$ python webserver3c.py
接着运行连个curl命令,下载即便服务器子进程处理客户端请求后休眠60秒,却并不影响其他客户端,因为他们是完全不同的独立进程了。你可以运行你尽可能多的curl命令(你想多少就多少),每一个都会没有明显延迟立即打印出服务器响应“Hello, World” 。
理解fork最重要的一点是你调用forl一次但是他返回两次:一次是父进程,一次是子进程。你fork你个新的进程返回子进程的ID是0,返回父进程的是子进程的PID。
我还记得第一次看到并尝试fork时是有多着迷。我正在看循序代码突然一声响:代码复制了自己成为两个同时运行的实例。我觉得这就是魔法,真的。
父进程fork出一个新子进程,这个子进程得到一个父进程文件描述符:
你可能注意到上面代码的父进程关闭了客户端连接:
else: # parent
client_connection.close # close parent copy and loop over
那么子进程怎么能继续读取客户端socket数据,如果父进程已经关闭了和它的连接?答案就在上面的图片中。内核根据文件描述符的值来决定是否关闭连接socket,只有其值为0才会关闭。服务器产生一个子进程,子进程拷贝父进程文件描述符,内核增加引用描述符的值。在一个父进程一个子进程的例子中,描述符引用值就是2,当父进程关闭连接socket,它只会把引用值减为1,不会小岛让内核关闭socket。子进程也关闭了父进程监听socket的重复拷贝,是因为它不关心接受新的客户端连接,而只在乎处理已连接客户端的响应:
listen_socket.close # close child copy
我会在这篇文章的后面谈到你不取消重复描述符会发生什么。
如你从当前服务器代码中看到的,父进程的唯一职责是接受客户端连接,fork一个子进程去处理客户端请求,然后继续接受另一个客户端请求,没别的。父进程不对客户端请求做处理——子进程来做。
我们说两个事件并发是什么意思呢?
说两事件并发通常是指他们在同一时间发生。作为一个简短的定义是好的,但是你应该记住严格的定义:
如果你不能通过看这个程序来告诉你,这两个事件是并发的。
又到了回顾概念和理念的时间了:
- 在UNIX下写并发服务器最简单的方法是用fork系统调用。
- 一个进程fork出一个新进程,它就变成新进程的父进程
- 调用fork后,父进程和子进程公用同样的文件描述符
- 内核用文件描述符应用值来决定关闭或打开文件/socket
- 服务器父进程的角色:从客户端接受新的连接,fork一个子进程去处理请求,继续接受新的连接。
我们来看看不取消父进程和子进程建重复描述符会发生什么。对个当前服务器代码稍作修改,webserver3d.py:
启动服务器:
$ python webserver3d.py
用curl连接服务器:
$ curl http://localhost:8888/hello
Hello, World!
curl打印出了并发服务器的响应但没有终止然后保持挂起。发生了什么?服务器不再休眠60秒:它的子进程积积德处理了客户端请求,关闭客户端连接和退出,但curl仍然不终止。
为什么curl不终止?答案是重复的文件描述符。子进程关闭了客户端连接,内核将socket引用值减为1。子进程退出,客户端socket还不关闭是因为socket的引用值还不是0,结果就是终止包(在TCP/IP中叫FIN)没有被发送到客户端,客户端就持续连接。还有一个问题,你一直运行服务器而不关闭重复的文件描述符,最终会用完可用的文件描述符。
用Control-C停止你的服务器,在shell通过内置命令ulimit检查你服务器可用的默认资源:
从上面你可以看到,在我的Ubuntu上open files文件描述符(打开多少文件)的最大可用数值是1024.
来看看不关闭重复描述符服务器怎样用完可用文件描述符。在终端窗口设置open files描述符为256:
$ ulimit -n 256
在同一终端启动服务器 webserver3d.py:
$ python webserver3d.py
用下面的客户端client3.py来测试服务器。
在一个新的终端窗口开启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 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+,而且进程名是了吗?这就是我们的僵尸进程。僵尸进程的问题是你不能杀死他们。
即使你用’$ kill -9’来杀僵尸进程,他们还会复活,你自己试试看。
什么是僵尸进程而我们的服务器为什么会产生他们?僵尸进程是一个已经终止进程,但是他的父进程没有等待并收到它的终止状态。
一个子进程先于它的父进程退出,内核将其转为僵尸进程并存储器父进程的一些信息用来以后恢复。通常存储进程ID,终止状态,进程使用的资源。所以僵尸进程是有用的,但你的服务器不处理好这些僵尸进程就会造成系统阻塞。看看吧,先停止运行的服务器,然后再新的终端窗口用ulimit命令设定你的最大用户进程为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异常,因为它已经达到了允许子进程数的上限。下面是我的异常截图:
我会简要说明服务器该怎样对待僵尸进程问题。
再回顾一下主要内容:
- 你不关闭重复描述符,客户端就不终止因为客户端连接没有关闭
- 你不关闭重复描述符,长时间运行的服务器最终会用完可用文件描述符
- 你fork的子进程退出了但是其父进程没等待和回收它的终止状态,那它就成了僵尸进程
- 你不能杀死僵尸进程,你需要等待它
那么你要做什么来对付僵尸进程?你要修改服务器代码来等待僵尸进程回收他们的终止状态。你可以用系统调用wait来修改服务器。不幸的是这太不理想,因为调用wait而没有终止的子进程的话,wait调用会锁住服务器,从而阻止服务器从处理新的客户端连接请求。有别的办法吗?有,其中一个是将一个信号处理器和wait调用结合。
它的工作原理是,一个子进程退出,内核发出一个SIGCHLD信号。父进程可以建一个信号处理器异步接收SIGCHLD信号,然后它就等待并回收子进程终止状态,从而防止留下僵尸进程。
顺便说下,异步事件意味着父进程不会提前知道该事件将要发生。
修改服务器代码,设置SIGCHLD事件处理器等待终止的子进程。代码是webserver3e.py:
启动服务器:
$ python webserver3e.py
用curl给修改过的服务器发送请求:
$ curl http://localhost:8888/hello
看看服务器怎么样:
发生了什么?因为错误EINTR调用accept失败。
子进程退出出发SIGCHLD事件然后父进程在调用accept时被锁住,父进程激活信号处理器完成工作后导致了系统调用accept中断:
别担心,这是个很好解决的问题。你需要的只是重启系统调用accept。这是修改的服务器用 webserver3f.py 来解决那个问题:
启动webserver3f.py:
$ python webserver3f.py
用curl给服务器发送请求:
$ curl http://localhost:8888/hello
看到了吧?没有EINTR异常了。现在,确定没有僵尸进程同时SIGCHLD事件处理器等待并处理子进程终止。运行ps命令不会在意python进程是Z+状态(没有进程)。太好了,没有僵尸进程就安全了。
- 如果你fork一个子进程却没有等待它,它会变僵尸进程
- 用SIGCHLD事件处理器异步等待终止的子进程回收它的终止状态
- 用事件处理器时你要记住系统调用可能终止,您需要为此做好准备方案
目前为止没什么问题,对吗?嗯,基本上是。在试试看webserver3f.py 但是不要用curl只发一个请求,用client3.py 发出128个同时连接:
$ python client3.py –max-clients 128
再次运行ps命令:
$ ps auxw | grep -i python | grep -v grep
看到了吧,天呐,僵尸进程又回来了!
这次是哪出错了?当运行128个链接且连接成功,服务器子进程处理请求并推出基本在同一时间,造成了SIGCHLD信号的洪流传向父进程。问题在于这些信号不排队,你的服务器就漏掉了一些信号,留下几个僵尸进程乱跑没人管:
解决方法是设一个SIGCHLD事件处理器但用WNOHANG来代替系统调用waitpid来排一个队,以确保所有终止进程都被处理。修改后代码 webserver3g.py:
启动服务器:
$ python webserver3g.py
用测试客户端client3.py:
$ python client3.py –max-clients 128
现在确认有没有僵尸进程。 好极了!生活是美好的:)
恭喜!这是一个相当漫长的旅程,但我希望你喜欢它。现在你有了简单的并发服务器,这代码可以作为在高曾次web服务器进一步的工作取的基础。
我把第二篇中WSGI服务器升级成并发服务器留给你当练习。你在这里可以找到修改的版本.但是只能在自己实现了之后看。你用完成它的所有信息,那就做吧: )
下来时什么呢?就像Josh Billings说的
要像一张邮票,坚持一件事情直到你到达目的地。
从掌握的基楚开始,质疑你已经知道的,始终深入。
如果你仅仅学习方法,你将被被你的方法束缚。但是如果你学习原则,你可以设计自己的方法。—— Ralph Waldo Emerson
下面是我在这篇文章引用素材的书单。他们会帮你扩大并深入我在文章中提到的知识。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。