单线程方式:

无论哪门编程语言,并发编程都是一项很常用很重要的技巧。例如,爬虫就被广泛应用在工业界的各个领域,我们每天在各个网站、各个 App 上获取的新闻信息,很大一部分便是通过并发编程版的爬虫获得。
正确合理地使用并发编程,无疑会给程序带来极大的性能提升。因此,本节就带领大家一起学习 Python 中的 Futures 并发编程。首先,先带领大家从代码的角度来理解并发编程中的 Futures,并进一步来比较其与单线程的性能区别。

# -*- coding:utf-8 -*-


import requests
import time


def download_one(url):
    resp = requests.get(url)
    print('Read {} from {}'.format(len(resp.content), url))


def download_all(sites):
    for site in sites:
        download_one(site)


def main():
    sites = [
        'https://www.tuicool.com/search?kw=pytorch+&t=1',
        'https://www.tuicool.com/a/',
        'https://www.tuicool.com/ah/101000000/'
    ]
    start_time = time.time()
    download_all(sites)
    end_time = time.time()
    print('Download {} sites in {} seconds'.format(len(sites), end_time - start_time))


if __name__ == '__main__':
    main()

输出结果:

Read 6215 from https://www.tuicool.com/search?kw=pytorch+&t=1
Read 8021 from https://www.tuicool.com/a/
Read 44835 from https://www.tuicool.com/ah/101000000/
Download 3 sites in 1.9893198013305664 seconds

Process finished with exit code 0

这种方式应该是最直接也最简单的:
先是遍历存储网站的列表;
然后对当前网站执行下载操作;
等到当前操作完成后,再对下一个网站进行同样的操作,一直到结束。

可以看到,总共耗时约 1.93s。单线程的优点是简单明了,但是明显效率低下,因为上述程序的绝大多数时间都浪费在了 I/O 等待上。程序每次对一个网站执行下载操作,都必须等到前一个网站下载完成后才能开始。如果放在实际生产环境中,我们需要下载的网站数量至少是以万为单位的,不难想象,这种方案根本行不通。

多线程方式:
接着再来看多线程版本的代码实现:

# -*- coding:utf-8 -*-



import concurrent.futures
import requests
import threading
import time

def download_one(url):
    resp = requests.get(url)
    print('Read {} from {}'.format(len(resp.content), url))

def download_all(sites):
    with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
        executor.map(download_one, sites)

def main():
    sites = [
        'https://www.tuicool.com/search?kw=pytorch+&t=1',
        'https://www.tuicool.com/a/',
        'https://www.tuicool.com/ah/101000000/'
    ]
    start_time = time.time()
    download_all(sites)
    end_time = time.time()
    print('Download {} sites in {} seconds'.format(len(sites), end_time - start_time))

if __name__ == '__main__':
    main()

输出结果:

Read 44835 from https://www.tuicool.com/ah/101000000/
Read 6220 from https://www.tuicool.com/search?kw=pytorch+&t=1
Read 8021 from https://www.tuicool.com/a/
Download 3 sites in 0.6527125835418701 seconds

Process finished with exit code 0

可以看到,总耗时是 0.65s 左右,效率一下子提升了很多。
注意,虽然线程的数量可以自己定义,但是线程数并不是越多越好,因为线程的创建、维护和删除也会有一定的开销,所以如果设置的很大,反而可能会导致速度变慢。我们往往需要根据实际的需求做一些测试,来寻找最优的线程数量。

多进程版本:

# -*- coding:utf-8 -*-



import concurrent.futures
import requests
import threading
import time

def download_one(url):
    resp = requests.get(url)
    print('Read {} from {}'.format(len(resp.content), url))

def download_all(sites):
    with concurrent.futures.ProcessPoolExecutor() as executor:
        executor.map(download_one, sites)

def main():
    sites = [
        'https://www.tuicool.com/search?kw=pytorch+&t=1',
        'https://www.tuicool.com/a/',
        'https://www.tuicool.com/ah/101000000/'
    ]
    start_time = time.time()
    download_all(sites)
    end_time = time.time()
    print('Download {} sites in {} seconds'.format(len(sites), end_time - start_time))

if __name__ == '__main__':
    main()

输出结果:

Read 44835 from https://www.tuicool.com/ah/101000000/
Read 8021 from https://www.tuicool.com/a/
Read 6229 from https://www.tuicool.com/search?kw=pytorch+&t=1
Download 3 sites in 1.164093255996704 seconds

Process finished with exit code 0

函数 ProcessPoolExecutor() 表示创建进程池,使用多个进程并行的执行程序。不过,这里通常省略参数 workers,因为系统会自动返回 CPU 的数量作为可以调用的进程数。
但是,并行的方式一般用在 CPU heavy 的场景中,因为对于 I/O heavy 的操作,多数时间都会用于等待,相比于多线程,使用多进程并不会提升效率。反而很多时候,因为 CPU 数量的限制,会导致其执行效率不如多线程版本。

异步方式:

# -*- coding:utf-8 -*-

import asyncio
import aiohttp
import time


async def download_one(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as resp:
            print('Read {} from {}'.format(resp.content_length, url))


async def download_all(sites):
    tasks = [asyncio.ensure_future(download_one(site)) for site in sites]
    await asyncio.gather(*tasks)


def main():
    sites = [
        'https://www.tuicool.com/search?kw=pytorch+&t=1',
        'https://www.tuicool.com/a/',
        'https://www.tuicool.com/ah/101000000/'
    ]
    start_time = time.perf_counter()

    loop = asyncio.get_event_loop()
    try:
        loop.run_until_complete(download_all(sites))
    finally:
        loop.close()

    end_time = time.perf_counter()
    print('Download {} sites in {} seconds'.format(len(sites), end_time - start_time))


if __name__ == '__main__':
    main()

运行结果:

Read None from https://www.tuicool.com/search?kw=pytorch+&t=1
Read None from https://www.tuicool.com/ah/101000000/
Read None from https://www.tuicool.com/a/
Download 3 sites in 1.1439555240569648 seconds

Process finished with exit code 0

事实上,Asyncio 和其他 Python 程序一样,是单线程的,它只有一个主线程,但可以进行多个不同的任务。这里的任务,指的就是特殊的 future 对象,我们可以把它类比成多线程版本里的多个线程。

这些不同的任务,被一个叫做事件循环(Event Loop)的对象所控制。所谓事件循环,是指主线程每次将执行序列中的任务清空后,就去事件队列中检查是否有等待执行的任务,如果有则每次取出一个推到执行序列中执行,这个过程是循环往复的。

为了简化讲解这个问题,可以假设任务只有两个状态:,分别是预备状态和等待状态:
预备状态是指任务目前空闲,但随时待命准备运行;
等待状态是指任务已经运行,但正在等待外部的操作完成,比如 I/O 操作。

在这种情况下,事件循环会维护两个任务列表,分别对应这两种状态,并且选取预备状态的一个任务(具体选取哪个任务,和其等待的时间长短、占用的资源等等相关)使其运行,一直到这个任务把控制权交还给事件循环为止。

当任务把控制权交还给事件循环对象时,它会根据其是否完成把任务放到预备或等待状态的列表,然后遍历等待状态列表的任务,查看他们是否完成:如果完成,则将其放到预备状态的列表;反之,则继续放在等待状态的列表。而原先在预备状态列表的任务位置仍旧不变,因为它们还未运行。

这样,当所有任务被重新放置在合适的列表后,新一轮的循环又开始了,事件循环对象继续从预备状态的列表中选取一个任务使其执行…如此周而复始,直到所有任务完成。

值得一提的是,对于 Asyncio 来说,它的任务在运行时不会被外部的一些因素打断,因此 Asyncio 内的操作不会出现竞争资源(多个线程同时使用同一资源)的情况,也就不需要担心线程安全的问题了。