跳过正文
  1. 博客/
  2. 后端/
  3. 网络/

HTTP复用

10 分钟· ·
后端 网络
作者
Allen
一个强大、轻量级的 Hugo 主题。
目录

曾经有人问过我一个问题什么是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个请求,在不修改配置的情况下这个值是固定的,所以我们提高并发数只有两种方法

  1. 修改系统配置
  2. 使用多进程

在写这篇文章之前,我一直以为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连接由四个部分组成

  1. 本地ip
  2. 本地port
  3. 服务器ip
  4. 服务器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
#

在上面我们验证了requestsSession对象的确实现连接池,但是似乎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_connectionspool_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复用
#

在这里我们使用Pythonaiohttp异步请求框架(在这里我们要求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复用如何是非常有帮助的。

引用:

Requests’ secret: pool_connections and pool_maxsize

Making 1 million requests with python-aiohttp

相关文章

Python异步的理解
6 分钟
后端 框架 Python
PostgreSQL的自增键
3 分钟
后端 数据库 PostgreSQL
协程解决递归错误原理
5 分钟
后端 框架 Python
爬虫分布式总结
3 分钟
后端 框架 Python
大数据学习小总结
1 分钟
后端 框架 大数据
ansible管理nginx负载均衡
4 分钟
后端 软件