曾经有人问过我一个问题什么是TCP复用,我当时没有回答上来,后面我又遇到一些并发性能问题的时候,我才开始慢慢明白为什么会有这个问题,以及这个问题背后的秘密
其实当时应该他想考我的是爬虫的请求优化,准确来说是HTTP持久连接(HTTP persistent connection),并不是TCP复用,这才导致我当时查阅很多资料,并没有发现TCP复用能优化客户端,因为TCP复用是服务端的事,现在就让我从源头开始慢慢解读这个问题
起因 #
我们知道我们每次发的HTTP请求在底层都是一个套接字的通信,我们可以从底层开始做一个测试
我们使用个for循环,申请1024个socket
import socket
l = [ ]
for i in range(1024):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(('www.baidu.com'),80))
print(i)
l.append(sock)
这个过程有点慢,但是你会发现在申请到1000左右的时候,会直接报Open too many file
这个错误,但是我们并没有打开文件,为什么会报这个错误
原来在Unix系统下,我们申请的套接字也就是socket
在底层是以文件的形式存在的,客户端通过申请一个socket
来写入和接受服务端的请求,这是一个非常重要的概念,对我们后面解析库函数有很大的帮助。
由于系统资源有限而且打开很多文件系统响应会变慢,所以Unix系统或者Windows都对单个进程申请套接字有限制,在Unix系统下我们可以通过ulimit -n
查看这个值,在笔者的Ubuntu
上这个值为1024,基本没有修改过都是这个值,我们可以通过我们可以在命令行执行ulimit -HSn 4096
临时增加至4096,
所以我们一般来说单台机器单个进程最多只能并发1024个请求,在不修改配置的情况下这个值是固定的,所以我们提高并发数只有两种方法
- 修改系统配置
- 使用多进程
在写这篇文章之前,我一直以为HTTP复用能在作用在并发上提高爬虫性能,但是其实并不是,它能提高性能但是却不是在并发上提高,接下来我们仔细介绍HTTP复用是怎么提高爬虫性能的
HTTP复用 #
说道HTTP复用,我们不得不介绍一下HTTP和TCP协议,我们都知道Internet是由OSI七层协议构成的,但是OSI只是规定了框架,具体协议我们是通过TCP/IP来实现的
我们先来说说这个TCP,我们都说互联网能够发展到现在这么稳定可靠多亏了这个TCP可靠协议,但是这个可靠是要付出代价的,建立一次连接的过程要经过三次握手,断开的过程也得四次分手,而且这个连接的过程完全不涉及我们要请求的内容,我们知道爬虫一般请求一个站点只有通过一两次请求就行,如果每次请求都得握三次手,还得分四次手,这样的代价也太大了
所以HTTP的复用优化的方向就是减少TCP的连接,谈到如何减少TCP连接,我们就得说说HTTP长连接(HTTP persistent connection)
HTTP长连接 #
在HTTP1.1规定了默认都是长连接,TCP不断开,并且在请求头添加一个Connection
的header,如果是值为keep-alive
则保留TCP连接,假如为Close
请求完成之后就会关闭,在HTTP1.0的下默认为关闭状态
怎么来理解这个长连接呢,我们都听说过HTTP是无状态的这句话,从HTTP协议上来看,服务器客户端就是一个“Request”,“Response”组成,无论多复杂的页面都是由一个个“Request”组成
为了更好的理解上面的话,我们回到那个套接字,我们把HTTP请求比作打电话,对于每个电话,我们只需要先拨号,然后滴滴滴三下后确定我们同对面连上了(服务器“协商”好),然后我们把我们要说的话通过话筒传给对方,等我们说完之后,由于信号差,对面听完还要想怎么回,然后我们安安静静的在听筒那等,等他想好说什么,在慢慢的说给我们听。
在HTTP1.0的时代,我们每次拨完一次好,说完一句话,听完对面的回应后,我们就会挂断电话,如果我们还想说就得再重复这个过程,在HTTP1.1下我们增加了长连接这个概念,就是如果你想这个电话里多聊几句,那么就在最后加上“你等下不要挂了,我还要说”(在header加上“Connection: Keep-alive”),那么对方就不会挂断电话,等它说完之后也想你一样在听筒那而等着,这样我们就省掉了一次拨号的时间
我们现在了解为什么HTTP复用能够节省爬虫的性能了,接下来我们就从编程语言对HTTP复用的实现上了解如何实现HTTP复用
存贮单元—ConnectionPool #
在介绍ConnectionPool之前我们先简单介绍一下HTTP复用的具体表现
TCP与URL的关系 #
我们知道HTTP复用的是TCP的连接,而TCP连接由四个部分组成
- 本地ip
- 本地port
- 服务器ip
- 服务器port
简单来说就是两个二元组(local_ip, local_port), (server_ip, server_port)
但是我们发一次的请求是一般是通过URL,也就是类似“http://www.baidu.com”,这样的url来请求的,这个同我们TCP有什么关系呢?
首先介绍一下“http”代表通信协议,这里使用的是HTTP协议,“://”后面的就是请求的域名,域名后面如果有冒号就是我们请求的端口号这里没有,根据HTTP协议这里默认是80端口(HTTPS是443),域名后面的就是请求路径,这里也没有就默认问“/”,也就是我们通过这个“url”就知道我们这次请求的具体位置了,现在我们找到了端口,但是请求的IP在哪呢?
这里就要介绍一下DNS了,我们为了让我们的站点更好记,我们使用域名代替ip地址,通过在DNS服务上注册我们域名,以及绑定我们域名对应的IP地址,我们就能让计算机通过域名来转换成IP地址,这里就不详细介绍了
所以呢我们现在了解了,一个TCP连接只是涉及到URL的域名和端口号,我们请求站点的时候主要是通过不同的路径来获取内容,所以我们可以很清楚的知道,只要我们URL的域名和端口一样,那么我们所以的URL都能共用这个TCP接口
ConnectionPool的实现 #
简单来说为了实现HTTP复用,我们只需要保存TCP连接就行了,但是通过前面我们知道,我们保留的TCP连接必须和你要请求的url要域名端口一样,有时候一个站点的服务可能由多个域名多个端口组成,所以原本我们只要用一个变量保留上一次请求的TCP连接,为了程序更加健壮,我们需要一个TCP连接池,存贮不同的TCP连接。
每次新的URL来的时候我们就是先从TCP连接池中查看有没有相同的域名和端口,如果有就用它发请求,如果没有就新建一个TCP连接,这就是TCP连接的基本原理,当然还要一点编程的时候要注意,我们从池子里面取出一个用完必须放回,否则池子用完了又得新建,那就完全丢掉了复用这个概念了
HTTP复用在Requests的具体表现 #
前面介绍了一大堆概念,但是从头到尾如果让我们自己来做一个实在太难了,幸好我们有Requests
这个库,它的Session
对象在文档介绍了它就维护了一个TCP连接池并且能够复用TCP连接
接下来我们就从代码入手来更好的理解这个进程池的高级用法,我们为了更好看到每一次请求底层的操作,我们这里自己先自己搭建一个本地服务器,我们使用Flask
来搭建一个本地服务器
新建一个web.py
文件,在运行
from flask import Flask, request
from werkzeug.serving import WSGIRequestHandler
app = Flask(__name__)
WSGIRequestHandler.protocol_version = "HTTP/1.1"
@app.route('/')
def hello_world():
return '%s %s' % (request.remote_addr, request.environ.get('REMOTE_PORT'))
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8000)
这里我们在8000端口开了一个服务器并且设置为HTTP/1.1协议,我们返回用户请求的ip和端口
接下来我们开一个Python解释器来看看这个进程池的用法
>>> import requests, logging
>>> logging.basicConfig(level=logging.DEBUG)
>>> session = requests.Session()
>>> session.get('https://baidu.com')
DEBUG:requests.packages.urllib3.connectionpool:Starting new HTTP connection (1): www.baidu.com
DEBUG:requests.packages.urllib3.connectionpool:http://www.baidu.com:80 "GET / HTTP/1.1" 200 None
<Response [200]>
看我们可以从打印的logging日志看到我们在进程池中新建了一个TCP连接,我们在试着再请求一次
>>> session.get('https://www.baidu.com')
DEBUG:requests.packages.urllib3.connectionpool:https://www.baidu.com:443 "GET / HTTP/1.1" 200 None
<Response [200]>
看我们的HTTP复用实现了,在同一个TCP连接中我们请求了两次
深入requests
的ConnectionPool
#
在上面我们验证了requests
的Session
对象的确实现连接池,但是似乎requests
并没有给我们接口来操作这个值,通过分析代码和资料,我们发现在Session
初始化的时候,绑定了一个 HTTPAdapter
对象,这个对象就是requests
封装了urllib3.connectionpool.ConnectionPool
来实现TCP池
我们查看这个HTTPAdapter
文档发现它的用法是这个
>>> import requests
>>> s = requests.Session()
>>> a = requests.adapters.HTTPAdapter(max_retries=3)
>>> s.mount('http://', a)
我们可以通过创建将一个TCP池绑定到一个session
对象上,我们可以看一下这个创建一个HTTPAdapter
的参数
HTTPAdapter(self, pool_connections=10, pool_maxsize=10, max_retries=0, pool_block=False)
我们主要看这两个参数pool_connections
和pool_maxsize
,通过一番测试(比较长就不演示了,可以参考引用来进行实验),我们发现这个pool_connections
主要控制TCP池的种类数,我们知道在进程池中我们可以有很多相同的TCP连接(主要是并发新建的),这些连接有些是连接相同的域名和端口,这个pool_connections
就是控制有多少种类的站点(域名和端口)同时能够存在池中,那么这个pool_maxsize
代表的就是池中不管种类有多少总共的TCP连接数
假如你只写单线程程序那么你只要考虑pool_connections
这个参数,因为单线程你发出一个请求只会占用一个TCP连接,在你每次开始请求时,池中不同站点的连接只有一种,所以你可以把pool_connections
当做池的大小,但是假如你写多线程程序,每个时间点需要的TCP连接同你多线程的个数有关,由于requests不会限制当池中无可用连接时新建TCP连接,所以你一个站点的TCP连接可能有多个,这时我们就要用pool_maxsize
来限制池子的容纳量,为了避免无限制存贮TCP连接,TCP连接池会把超过总数的连接按照时间顺序踢出去,让池中保持不大于限制总数的TCP连接。
当然这里有个非常重要的知识点,requests的TCP池并不会限制新建TCP连接,它只是限制存贮量和种类,这个知识点非常重要,这对后面我们理解aiohttp
异步请求时候为什么要限制并发数有非常大的帮助(它只限制TCP连接总数)
TCP连接池的作用 #
经过上面的探索,我们知道TCP连接池一方面能够实现HTTP复用达到减少TCP连接时耗的作用,另一方面我们通过复用TCP连接可以节省套接字,避免经常碰到”Too many file“的错误,顺便提一下,由于TCP连接具有冷启动的特点,在刚连接上TCP时,速度会非常慢,只有系统发现负载不多才会恢复正常速度,所以这就是我们有时候用浏览器打开一个新页面要加载很久的原因。
前面一直在介绍HTTP复用的理论基础,最后我们实战演练一下在异步框架aiohttp
使用HTTP复用
异步框架下HTTP复用 #
在这里我们使用Python
的aiohttp
异步请求框架(在这里我们要求Python
的版本必须大于等于3.5),aiohttp
也提供了TCP
连接池的功能,要想共享TCP连接池,我们先新建一个Session
对象
connector = aiohttp.TCPConnector(limit=50)
session = aiohttp.ClientSession(connector=connector)
我们直接创建了一个最大容量为50的TCP池,并把它绑定到session对象上,接下来先试试跑个200个请求(要先在按照前面的代码搭建本地服务器)
async def fetch(url, session, semaphore):
async with semaphore:
async with session.get(url) as response:
print(await response.read())
loop = asyncio.get_event_loop()
接下来我们就可以直接使用aiohttp框架
semaphore = asyncio.Semaphore(20)
tasks = [fetch(url, session, semaphore) for x in range(nums)]
begin = time.time()
try:
loop = asyncio.get_event_loop()
loop.run_until_complete(asyncio.wait(tasks))
except:
pass
finally:
end = time.time()
loop.close()
session.close()
print('cost', end - begin, 'speed', nums / (end - begin), 'req/s')
在我的电脑测试测试下421.73 req/s
,基本上达到异步的效率(可以调节limit至100左右达到最大)
在这里解释一下为什么要使用semaphore
(asyncio锁),由于当前版本(aiohttp==2.2.5)下aiohttp
的HTTP连接池无法在没有锁的情况下复用TCP连接(具体可以看一下我提的这个issue,这里由于牵扯到太多异步框架的知识,我就详细不介绍异步库,如果想了解更多的话就看我上一片博文Python异步的理解
总结 #
在如何提高请求效率和速度上,HTTP复用算是从协议出发上的一种优化,他主要利用方向是在单个站点多次请求上面,假如每个站点都只是一个请求的话,那他就无用武之地,不过现在站点不可能一次请求就完成交互,所以了解这个HTTP复用如何是非常有帮助的。
引用: