Contents
  1. 1. 起因
  2. 2. HTTP复用
    1. 2.1. HTTP长连接
  3. 3. 存贮单元—ConnectionPool
    1. 3.1. TCP与URL的关系
    2. 3.2. ConnectionPool的实现
  4. 4. HTTP复用在Requests的具体表现
  5. 5. 深入requests的ConnectionPool
  6. 6. TCP连接池的作用
  7. 7. 异步框架下HTTP复用
  8. 8. 总结

曾经有人问过我一个问题什么是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

Contents
  1. 1. 起因
  2. 2. HTTP复用
    1. 2.1. HTTP长连接
  3. 3. 存贮单元—ConnectionPool
    1. 3.1. TCP与URL的关系
    2. 3.2. ConnectionPool的实现
  4. 4. HTTP复用在Requests的具体表现
  5. 5. 深入requests的ConnectionPool
  6. 6. TCP连接池的作用
  7. 7. 异步框架下HTTP复用
  8. 8. 总结