在爬虫开发中,进程和线程的概念非常重要的,提高爬虫的工作效率,打造分布式爬虫,都离不开进程和线程的身影。本节将从多进程、多线程、协程三个方面,帮组大家回顾 Python 语言中进程和线程的常用操作,以便在接下来的爬虫开发中灵活运用进程和线程。
Python 实现多进程的方式主要有两种,一种方法是使用 os 模块中的 fork 方法,另一种方法是使用 multiprocessing 模块。这两种方法的区别在于前者仅使用 Unix/Linux 操作系统,对 Windows 不支持,后者则是跨平台的实现方式。由于现在很多爬虫程序都运行在 Unix/Linux 操作系统上,所以本节对两种方式进行讲解
使用 os 模块中的 fork 方式实现多进程
Python 的 os 模块封装了常见的系统调用,其中就有 fork 方法。fork 方法来自Unix/Linux 操作系统中提供的一个 fork 系统调用,这个方法非常特殊。普通的方法都是调用一次返回一次,而 fork 方法是调用一次返回两次,原因在于操作系统将当前进程(父进程)复制出一份进程(子进程),这两个进程几乎完全相同,于是 fork 方法分别在父进程和子进程中返回。子进程中永远返回 0,副进程中返回的是子进程的 ID。下面举个例子,对Python使用 fork 方法创建进程进行讲解。其中 os 模块中的 getpid 方法用于获取当前进程 ID,getppid 方法获取父进程的 ID。代码如下:
import os
if __name__ == '__main__':
print('current Process (%s) start ...' % (os.getpid()))
pid = os.fork()
if pid < 0:
print('error in fork')
elif pid == 0:
print('I am child process(%s) and my parent process is (%s)', os.getpid(), os.getppid())
else:
print('I(%s) created a chlid process (%s).', (os.getpid(), pid))
>>>
current Process (64764) start ...
I(64763) created a chlid process (64765).
I(64764) created a chlid process (64766).
I am child process(64765) and my parent process is (64763)
I am child process(64766) and my parent process is (64764)
使用 multiprocessing 模块创建多线程
multiprocessimg 模块提供一个 Process 类来描述一个进程对象。创建子进程时,只需要传入一个执行函数和函数的参数,即可完成一个 Process 实例的创建,用 start() 方法启动进程,用 join() 方法实现进程间的同步。下面通过一个例子来演示创建多进程的流程,代码如下:
import os
from multiprocessing import Process
def run_proc(name):
print('Child process %s (%s) Running...' % (name, os.getpid()))
if __name__ == '__main__':
print('parent process %s' % (os.getpid()))
for i in range(5):
p = Process(target=run_proc, args=str(i))
print('Process will start')
p.start()
p.join()
print('Process end.')
以上介绍了创建进程的两种方法,但是要启动大量的子进程,使用进程池批量创建子进程的方式更加常见,因为当被操作的对象数目不大时,可以直接利用 multiprocessing 中的 Process 动态生成多个进程,如果上百个、上千个目标,手动去限制进程数量却又太过频繁,这时候进程池 Pool 发挥作用的时候就到了。
multiprocessing 模块提供了一个 Pool 类来代表进程池对象
Pool 可以提供指定数量的进程提供用户调用,默认大小是 CPU 的核数。当有新的请求提交到 Pool 中时,如果池还没有满,那么就会创建一个新的进程用来执行该请求;但如果池中的进程数已经达到规定的最大值,那么该请求就会等待,知道池中有进程结束,才会创建新的进程来处理它。下面通过一个例子来演示进程池的工作流程,代码如下:
import os
import time
import random
from multiprocessing import Pool
def run_task(name):
print('Task {} (pid={}) is running...'.format(name, os.getpid()))
time.sleep(random.random() * 3)
print('Task {} end.'.format(name))
if __name__ == '__main__':
print('Current process {}'.format(os.getpid()))
p = Pool(processes=3)
for i in range(5):
p.apply_async(run_task, args=(i, ))
print('Waiting for all subprocesses done...')
p.close()
p.join()
print('All processes done...')
>>>>
Current process 70694
Waiting for all subprocesses done...
Task 0 (pid=70696) is running...
Task 1 (pid=70697) is running...
Task 2 (pid=70698) is running...
Task 0 end.
Task 3 (pid=70696) is running...
Task 1 end.
Task 4 (pid=70697) is running...
Task 3 end.
Task 4 end.
Task 2 end.
All processes done...
上述程序先创建了容量为3的进程池,依次向进程池中添加5个任务。从运行结果中可以看到虽然添加5个任务,但是一开始只运行了3个,而且每次最多运行3个进程。当一个任务结束了,新的任务一次添加进来,任务执行使用的进程依然是原来的进程,这一点通过进程的 pid 可以看出来。
注意⚠️:Pool 对象调用 join() 方法会等待所有子进程执行完毕,调用 join() 之前必须先调用 close(),调用 close() 之后就不能继续添加新的 Process 了。
进程间的通信
假如创建大量的进程,那么进程间的通信是必不可少的。Python 提供了多种进程间通信的方式,例如:Queue、Pipe、Value+Array 等。本节主要讲解 Queue 和 Pipe 这两种方式。Queue 和 Pipe 的区别在于 Pipe 常用来在两个进程间通信,Queue 用来在多个进程间实现通信。
首先讲解一下 Queue 通信方式。Queue 是多进程安全的队列,可以使用 Queue 实现多进程之间的数据传递。有两个方法:Put 和 Get 可以进行 Queue 操作:
下面通过一个例子进行说明:在父进程中创建三个子进程,两个子进程往 Queue 中写入数据,一个子进程从 Queue 中读取数据。程序实例如下所示:
# 进程间的通信
from multiprocessing import Process, Queue
import os
import time
import random
def proc_write(q, urls):
print('Process ({}) is writing...'.format(os.getpid()))
for url in urls:
q.put(url)
print('Put {} to queue...'.format(url))
time.sleep(random.random())
# 读数据进程执行的代码
def proc_read(q):
print('Process ({}) is reading...'.format(os.getpid()))
while 1:
url = q.get(True)
print('Get {} from queue'.format(url))
if __name__ == '__main__':
# 父进程创建 Queue,并传给各个子进程
q = Queue()
proc_write1 = Process(target=proc_write, args=(q, ['url1', 'url2', 'url3']))
proc_write2 = Process(target=proc_write, args=(q, ['url4', 'url5', 'url6']))
proc_reader = Process(target=proc_read, args=(q,))
# 启动子进程 proc_write 写入
proc_write1.start()
proc_write2.start()
# 启动子进程 proc_read 读取
proc_reader.start()
# 等待 proc_write 结束
proc_write1.join()
proc_write2.join()
# proc_read 进程死循环,无法等待其结束,只能强制终止
proc_reader.terminate()
>>>
Process (76534) is writing...
Put url1 to queue...
Process (76535) is writing...
Put url4 to queue...
Process (76536) is reading...
Get url1 from queue
Get url4 from queue
Put url2 to queue...
Get url2 from queue
Put url5 to queue...
Get url5 from queue
Put url3 to queue...
Get url3 from queue
Put url6 to queue...
Get url6 from queue
最后介绍一下 Pipe 的通信机制, Pipe 常用来在两个进程间进行通信,两个进程分别位于管道的两端。
Pipe 方法返回(conn1,conn2)代表一个管道的两个端,Pipe 方法有 duplex 参数,如果 duplex 参数为 True(默认值),那么这个管道就是全双工模式,也就是说 conn1 和 conn2 均可收发。若 duplex 为 False,conn1 只负责接受消息,conn2 只负责发送消息。send 和 recv 方法分别是发送和接收消息的方法。例如,在全双工模式下,可以调用 conn1.send 发送消息,conn1.recv接收消息。如果没有消息可接收,recv 方法会一直阻塞。如果管道已经被关闭,那么 recv 方法会抛出 EOFError。
下面通过一个例子进行说明:创建两个进程,一个子进程通过 Pipe 发送数据,一个子进程通过 Pipe 接收数据。程序如下
# Pipe 进程间的通信
import multiprocessing
import random
import time
import os
def proc_send(pipe, msgs):
for m in msgs:
print('Process ({}) send: {}'.format(os.getpid(), m))
pipe.send(m)
time.sleep(random.random())
def proc_recv(pipe):
while 1:
print('Process ({}) rev: {}'.format(os.getpid(), pipe.recv()))
time.sleep(random.random())
if __name__ == '__main__':
pipe = multiprocessing.Pipe()
print('>>>', pipe)
p1 = multiprocessing.Process(target=proc_send, args=(pipe[0], ['url'+str(i) for i in range(10)]))
p2 = multiprocessing.Process(target=proc_recv, args=(pipe[1], ))
p1.start()
p2.start()
p1.join()
# p2 死循环,只能强制结束
p2.terminate()
>>>>
>>> (<multiprocessing.connection.Connection object at 0x10f668160>, <multiprocessing.connection.Connection object at 0x10f668460>)
Process (77371) send: url0
Process (77372) rev: url0
Process (77371) send: url1
Process (77371) send: url2
Process (77372) rev: url1
Process (77371) send: url3
Process (77371) send: url4
Process (77371) send: url5
Process (77372) rev: url2
Process (77372) rev: url3
Process (77371) send: url6
Process (77372) rev: url4
Process (77372) rev: url5
Process (77372) rev: url6
Process (77371) send: url7
Process (77371) send: url8
Process (77372) rev: url7
Process (77372) rev: url8
Process (77371) send: url9
多线程类似于同时执行多个不同程序,多线程运行有如下优点:
Python 的标准库提供了两个模块:thread 和 threading,thread 是低级模块,threading 是高级模块,对 thread 进行了封装。绝大多数情况下,我们只需要使用 threading 高级模块
用 threading 模块创建多线程
threading 模块一般通过两种方式创建多线程:
第一种方法实例:
# 多线程实例
import random
import time
import threading
def thread_run(urls):
print('Current {} is running...'.format(threading.current_thread().name))
for u in urls:
print('{} ---> {}'.format(threading.current_thread().name, u))
time.sleep(random.random())
print('{} ended.'.format(threading.current_thread().name))
print('Current {} is running...'.format(threading.current_thread().name))
t1 = threading.Thread(target=thread_run, name='Thread1', args=(['url1', 'url2'],))
t2 = threading.Thread(target=thread_run, name='Thread2', args=(['url3', 'url4'],))
t1.start()
t2.start()
t1.join()
t2.join()
print('{} overed...'.format(threading.current_thread().name))
>>>>
Current MainThread is running...
Current Thread1 is running...
Thread1 ---> url1
Current Thread2 is running...
Thread2 ---> url3
Thread1 ---> url2
Thread2 ---> url4
Thread2 ended.
Thread1 ended.
MainThread overed...
第二种方法实例:
从 threading.Thread 继承创建线程类,下面将方法一的程序进行重写,程序如下:
import random
import threading
import time
class MyThread(threading.Thread):
def __init__(self, name, urls):
threading.Thread.__init__(self, name=name)
self.urls = urls
def run(self):
print('Current {} is running...'.format(threading.current_thread().name))
for u in self.urls:
print('{} ---> {}'.format(threading.current_thread().name, u))
time.sleep(random.random())
print('{} ended.'.format(threading.current_thread().name))
t1 = MyThread(name='thread1', urls=['url1', 'url2'])
t2 = MyThread(name='thread2', urls=['url3', 'url4'])
t1.start()
t2.start()
t1.join()
t2.join()
print('{} overed.'.format(threading.current_thread().name))
>>>
Current thread1 is running...
thread1 ---> url1
Current thread2 is running...
thread2 ---> url3
thread1 ---> url2
thread2 ---> url4
thread2 ended.
thread1 ended.
MainThread overed.
线程同步
如果多个线程共同对某一个数据修改,则可能出现不可预料的结果,为了保证数据的恶正确性,需要对多个线程进行同步。使用 Thread 对象 Lock 和 RLock 可以实现简单的线程同步,这两个对象都有 acquire 方法和 release 方法,对于那些每次只允许一个线程操作的数据,可以将其操作放到 acquire 和 release 方法之间。
对于 Lock 对象而言,如果一个线程连续两次进行 acquire 操作,那么由于第一次 acquire 之后没有 release。第二次 acquire 将挂起线程。这会导致 Lock 对象永远不会 release,使的线程死锁。RLock 对象允许一个线程多次对其进行 acquire 操作,因为在其内部通过一个 counter 变量维护着线程 acquire 的次数,而且每次的 acquire 操作必须有一个 release 操作与之对应,在所有的 release 操作完成之后,别的线程才能申请该 RLock 对象。下面通过一个简单的例子演示线程同步的过程:
# 线程锁 Lock RLock
import threading
mylock = threading.RLock()
num = 0
class MyThread(threading.Thread):
def __init__(self, name):
super().__init__(name=name)
def run(self):
global num
while 1:
mylock.acquire()
print('{} locked, Number: '.format(threading.current_thread().name), num)
if 4 <= num:
mylock.release()
print('{} release, Number: {}'.format(threading.current_thread().name, num))
break
num += 1
print('{} release, Number: {}'.format(threading.current_thread().name, num))
mylock.release()
if __name__ == '__main__':
thread1 = MyThread('thread1')
thread2 = MyThread('thread2')
thread1.start()
thread2.start()
>>>>
thread1 locked, Number: 0
thread1 release, Number: 1
thread1 locked, Number: 1
thread1 release, Number: 2
thread1 locked, Number: 2
thread1 release, Number: 3
thread1 locked, Number: 3
thread1 release, Number: 4
thread1 locked, Number: 4
thread1 release, Number: 4
thread2 locked, Number: 4
thread2 release, Number: 4
全局解释器锁(GIL)
在 Python 的原始解释器 CPython 中存在着 GIL (Global Interpreter Lock,全局解释器锁),因此在解释执行 Python 代码时,会产生互斥锁来限制线程对共享资源的访问,知道解释器遇到 I/O 操作或者次数达到一定数目时才会释放GIL。由于全局解释器锁的存在,在进行多线程操作的时候,不能调用多个 CPU 内核,只能利用一个内核,所以在进行 CPU 密集型操作时,不推荐使用多线程,更加倾向于多线程,那么多线程适合什么样的应用场景呢?对于 IO 密集型操作,多线程可以明显提高效率,例如 Python 爬虫的开发,绝大多数时间爬虫实在等待 socket 返回数据,网络 IO 的操作延迟比 CPU 大得多。
总结:由于多线程其实时运行在一个进程中,不能调用多个 CPU 内核,只能利用一个内核。所以进行 CPU 密集型操作时,不推荐使用多线程,更倾向于多线程。
在进行 IO 密集型操作时推荐使用多线程,可以明显提高效率。
协程(coroutine),又称微线程,纤程,是一种用户级的轻量级线程。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此协程能保留上一次调用时的状态,每次过程重入时,就相当于进入上一次调用的状态。在并发编程中,写成与线程类似,每个协程表示一个执行单元,有自己的本地数据,与其他写成共享全局数据和其他资源。
协程需要用户自己来编写调度逻辑,对于 CPU 来说,协程其实是单线程,所以 CPU 不用去考虑怎么调度、切换上下文,这就省去了 CPU 的切换开销,所以协程在一定程度上又好于多线程。那么在 Python 中是如何实现协程的呢?
Python 用过 yield 提供了对协程的基本支持,但是不完全,而使用第三方 gevent 库是更好的选择,gevent 提供了比较完善的协程支持。gevent 是一个基于协程的 Python 网络函数库,使用greenlet在 libev 事件循环顶部提供了一个有高级别并发性的 API。主要特性有以下几点:
gevent 对协程的支持,本质上 greenlet 在实现切换工作。greenlet 工作流程如下:
假如进行访问网络的 IO 操作时,出现阻塞,greenlet 就显式切换到另一段没有被阻塞的代码段执行,直到原来的阻塞状况消失以后,再自动切换回原来的代码段继续处理。因此 greenlet 是一种合理安排的串行方式。
由于IO操作非常耗时,经常使程序处于等待状态,有了 gevent 为我们自动切换协程,就保证总有 greenlet 在运行,而不是等待 IO,这就是协程比一般线程效率高的原因。由于切换是在 IO 操作时自动完成,所以 gevent 需要修改 Python 自带的一些标准库,将一些常见的阻塞,如 socket、select 等地方实现协程跳转,这一过程在启动时通过 monkey patch 完成。下面通过一个例子来演示 gevent 使用流程,代码如下:
from gevent import monkey
monkey.patch_all()
import gevent
import requests
def run_task(url):
print('Visit >>>> {}'.format(url))
try:
res = requests.get(url)
data = res.text
print('{} bytes received from {}'.format(len(data), url))
except Exception as e:
print(e)
if __name__ == '__main__':
urls = ['https://www.baidu.com', 'https://www.cnblogs.com']
greenlets = [gevent.spawn(run_task, url) for url in urls]
gevent.joinall(greenlets)
>>>
Visit >>>> https://www.baidu.com
Visit >>>> https://www.cnblogs.com
2443 bytes received from https://www.baidu.com
69457 bytes received from https://www.cnblogs.com
以上程序主要用了 gevent 中的 spawn 方法和 joinall 方法。spawn 方法可以看做是用来形成协程,joinall 方法就是添加这些协程任务,并且启动运行,从运行结果看,2个网络操作是并发执行的,而且结束顺序不同,但其实只有一个线程。
gevent 中还提供了对池的支持。当拥有动态数量的 greenlet 需要进行并发管理(限制并发数)时,就可以使用池,这在处理大量的网络和 IO 操作时是非常重要的,接下来使用 gevent 中 pool 对象,对上面的例子进行改写,程序如下:
from gevent import monkey
monkey.patch_all()
from gevent.pool import Pool
import requests
def run_task(url):
print('Visit >>>> {}'.format(url))
try:
res = requests.get(url)
data = res.text
print('{} bytes received from {}'.format(len(data), url))
except Exception as e:
print(e)
if __name__ == '__main__':
pool = Pool(2)
urls = ['https://www.baidu.com', 'https://www.cnblogs.com', 'https://mo.fish']
pool.map(run_task, urls)
>>>
Visit >>>> https://www.baidu.com
Visit >>>> https://www.cnblogs.com
2443 bytes received from https://www.baidu.com
Visit >>>> https://mo.fish
3497 bytes received from https://mo.fish
69458 bytes received from https://www.cnblogs.com
通过运行结果可以看出,Pool 对象确实对协程的并发数量进行管理,先访问前面两个网址,当其中一个任务完成时,才会执行第三个。