Python网络爬虫案例实战:并发与 Web:并发和并行、同步和异步、阻塞与非阻塞
Python 支持的并发分为多进程并发与多线程并发。概念上来说,多进程并发即运行多个独立的程序,优势在于并发处理的任务都由操作系统管理,不足之处在于程序与各进程之间的通信和数据共享不方便;多线程并发则由程序员管理并发处理的任务,这种并发方式可以方便地在线程间共享数据(前提是不能互斥)。Python对多进程和多线程的支持都比一般编程语言更高级,最大限度地减少了需要我们完成的工作。
6.1并发和并行、同步和异步、阻塞与非阻塞
在介绍多线程爬虫之前,首先需要熟悉并发和并行、同步和异步、阻塞与非阻塞的概念。
6.1.1并发和并行
并发(concurrency)和并行(parallelism)是两个相似的概念。引用一个比较容易理解的说法,并发是指在一个时间段内发生若干事件的情况,并行是指在同一时刻发生若干事件的情况。
这个概念用单核CPU(见图6-1)和多核 CPU(见图6-2)比较容易说明。在使用单核CPU时,多个工作任务是以并发方式运行的,因为只有一个CPU,所以各个任务会分别占用 CPU的一段时间依次执行。如果在自己分得的时间段没有完成任务,就会切换到 CPU的一段时间依次执行。如果在自己分得的时间段没有完成任务,就会切换到另一个任务,然后在下一次得到 CPU 使用权的时候再继续执行,直到完成。在这种情况下,因为各个任务的时间段很短、经常切换,所以给我们的感觉是“同时”进行。在使用多核 CPU时,在各个核的任务能够同时运行,这是真正的同时运行,也就是并行。
并行情况:CPU核心数量大于或等于进程数量。
以吃一碗米饭和一盘菜的任务为例,“并发”就是一个人吃,这个人吃一口菜然后吃一口饭,由于切换速度比较快,让你觉得他在“同时”吃菜和吃饭;“并行”就是两个人同时吃,一个人吃饭,一个人吃菜。
下面以两个例子来进一步说明。例如:
[例6-1]创建两个线程,演示并发和并行效果1。
import threading #线程
import time
def music():
print('begin to listen music {}'.format(time.ctime()))
time.sleep(3)
print('stop to listen music {}'.format(time.ctime()))
def game():
print('begin to play game {}'.format(time.ctime()))
time.sleep(5)
print('stop to play game {}'.format(time.ctime()))
if __name__ == '__main__':
music()
game()
print('ending.....')
输出:
begin to listen music Tue Aug 13 15:02:52 2024
stop to listen music Tue Aug 13 15:02:55 2024
begin to play game Tue Aug 13 15:02:55 2024
stop to play game Tue Aug 13 15:03:00 2024
ending.....
music的时间为3s,game的时间为5s,如果按照正常的方式执行,直接执行函数,那么将按顺序执行,整个过程为8s。
[例6-2]创建两个线程,演示并发和并行效果2。
import threading #线程
import time
def music():
print('begin to listen music {}'.format(time.ctime()))
time.sleep(3)
print('stop to listen music {}'.format(time.ctime()))
def game():
print('begin to play game {}'.format(time.ctime()))
time.sleep(5)
print('stop to play game {}'.format(time.ctime()))
if __name__ == '__main__':
t1 = threading.Thread(target=music) #创建一个线程对象t1 子线程
t2 = threading.Thread(target=game) #创建一个线程对象t2 子线程
t1.start()
t2.start()
# t1.join() #等待子线程执行完 t1不执行完,谁也不准往下走
t2.join()
print('ending.......') #主线程
print(time.ctime())
输出:
begin to listen music Tue Aug 13 15:05:21 2024
begin to play game Tue Aug 13 15:05:21 2024
stop to listen music Tue Aug 13 15:05:24 2024
stop to play game Tue Aug 13 15:05:26 2024
ending.......
Tue Aug 13 15:05:26 2024
在这个例子中,开启了两个线程,将music和 game两个函数分别通过线程执行,运行结果显示两个线程同时开始,由于听音乐时间3秒,玩游戏时间5秒,所以整个过程完成时间为5秒。我们发现,通过开启多个线程,原本8秒的时间缩短为5秒,原本顺序执行现在看起来是不是并行执行的?看起来好像是这样,听音乐的同时在玩游戏,整个过程的时间随最长的任务时间变化。但真的是这样吗?那么下面提出一个GIL锁的概念。全局解释器锁简称GIL,即指无论你开启多少个线程,你有多少个CPU,Python在执行的时候会在同一时刻只允许一个线程运行。
[例 6-3]全局解释器锁应用实例1。
import time
from threading import Thread
def add():
sum = 0
i = 1
while i<=1000000:
sum += i
i += 1
print('sum:',sum)
def mul():
sum2 = 1
i = 1
while i<=100000:
sum2 = sum2 * i
i += 1
print('sum2:',sum2)
start = time.time()
add()
mul() #串行比多线程还快
print('cost time %s'%(time.time()-start))
输出:
cost time 18.00896382331848
[例 6-4]全局解释器锁应用实例2。
import time
from threading import Thread
def add():
sum = 0
i = 1
while i<=1000000:
sum += i
i += 1
print('sum:',sum)
def mul():
sum2 = 1
i = 1
while i<=100000:
sum2 = sum2 * i
i += 1
print('sum2:',sum2)
start = time.time()
t1 = Thread(target=add)
t2 = Thread(target=mul)
l = []
l.append(t1)
l.append(t2)
for t in l:
t.start()
for t in l:
t.join()
print('cost time %s'%(time.time()-start))
输出:
cost time 18.0064857006073
由以上两例题的结果可以发现:单线程执行比多线程还快?这是不符合常理的。究其原因是:与GIL锁有关,同一时刻,系统只允许一个线程执行,也就是说,本质上我们之前理解的多线程的并行是不存在的,那么之前的例子为什么时间确实缩短了呢?这里涉及一个任务的类型。
得到一个结论:由于GIL锁,多线程不可能真正实现并行。所谓并行,也只是宏观上并行微观上并发,本质上是由于遇到IO操作而不断地进行 CPU切换所造成并行的现象。由于 CPU切换速度极快,所以看起来就像是在同时执行。
6.1.2同步与异步
同步和异步也是两个值得比较的概念。下面在并发和并行框架的基础上理解同步和异步,同步就是并发或并行的各个任务不是独自运行的,任务之间有一定的交替顺序,可能在运行完一个任务得到结果后,另一个任务才会开始运行。就像接力赛跑一样,要拿到交接棒之后下一个选手才可以开始跑。
异步则是并发或并行的各个任务可以独立运行,一个任务的运行不受另一个任务的影响,任务之间就像比赛的各个选手在不同的赛道比赛一样,跑步的速度不受其他赛道选手的影响。
在网络爬虫中,假设你需要打开4个不同的网站,IO过程就相当于你打开网站的过程,CPU 就是你单击的动作。你单击的动作很快,但是网站打开得很慢。同步IO是指你每单击一个网址,要等待该网站彻底显示才可以单击下一个网址,也就是我们之前学过的爬虫方式。异步IO是指你单击完一个网址,不用等对方服务器返回结果,马上可以用新打开的浏览器窗口打开另一个网址,以此类推,最后同时等待4个网站彻底打开。
[例 6-5]下面看一下异步的一个例子。
import socket
import select
class HttpRequest:
def __init__(self, sk, host, callback):
self.socket = sk
self.host = host
self.callback = callback
def fileno(self):
return self.socket.fileno()
class HttpResponse:
def __init__(self, recv_data):
self.recv_data = recv_data
self.header_dict = {
}
self.body = None
self.initialize()
def initialize(self):
headers, body = self.recv_data.split(b'\r\n\r\n', 1)
self.body = body
header_list = headers.split(b'\r\n')
self.header_dict = {
line.split(b':', 1)[0].decode('utf-8'): line.split(b':', 1)[1].decode('utf-8') for line in header_list if b':' in line}
class AsyncRequest:
def __init__(self):
self.conn = []
self.connection = []
def add_request(self, host, callback):
try:
sk = socket.socket()
sk.setblocking(False)
sk.settimeout(10)
sk.connect((host, 80))
except BlockingIOError:
pass
request = HttpRequest(sk, host, callback)
self.conn.append(request)
self.connection.append(request)
def run(self):
while self.conn:
rlist, wlist, elist = select.select(self.conn, self.connection, self.conn, 0.05)
for w in wlist:
print(f'{
w.host} 连接成功...')
tpl = f"GET / HTTP/1.0\r\nHost: {
w.host}\r\n\r\n"
try:
w.socket.send(tpl.encode('utf-8'))
except socket.error as e:
print(f"Send error: {
e}")
self.connection.remove(w)
for r in rlist:
recv_data = b''
try:
while True:
chunck = r.socket.recv(8096)
if not chunck:
break
recv_data += chunck
except socket.error as e:
print(f"Receive error: {
e}")
response = HttpResponse(recv_data)
r.callback(response)
r.socket.close()
self.conn.remove(r)
def f1(response):
print('保存到文件', response.header_dict)
def f2(response):
print('保存到数据库', response.header_dict)
url_list = [
{
'host': 'www.youku.com', 'callback': f1},
{
'host': 'v.qq.com', 'callback': f2},
{
'host': 'www.cnblogs.com', 'callback': f2},
]
req = AsyncRequest()
for item in url_list:
req.add_request(item['host'], item['callback'])
req.run()
输出:
www.youku.com 连接成功...
v.qq.com 连接成功...
www.cnblogs.com 连接成功...
保存到数据库 {
'Server': ' NWS_TCloud_PX', 'Connection': ' close', 'Date': ' Tue, 13 Aug 2024 07:15:27 GMT', 'Content-Length': ' 22', 'Location': ' https://v.qq.com/'}
保存到数据库 {
'Date': ' Tue, 13 Aug 2024 07:15:28 GMT', 'Content-Type': ' text/html', 'Content-Length': ' 154', 'Connection': ' close', 'Location': ' https://www.cnblogs.com/', 'Via': ' HTTP/1.0 SLB.47'}
保存到文件 {
'Date': ' Tue, 13 Aug 2024 07:15:28 GMT', 'Content-Type': ' text/html', 'Content-Length': ' 357', 'Connection': ' close', 'Location': ' https://www.youku.com/', 'Server': ' Tengine/Aserver', 'EagleEye-TraceId': ' 213e6d6d17235333281846100edfa8', 'Timing-Allow-Origin': ' *', 's-rt': ' 0'}
由结果可以看到,3个请求的发送顺序与返回顺序并不一样,这就体现了异步请求。即同时将请求发送出去,哪个先回来先处理哪个。也可以理解为:我打电话的时候只允许和一个人通信,和这个人通信结束之后才允许和另一个人开始,这就是同步。我们发短信的时候发完可以不去等待,去处理其他事情,当对方回复之后再去处理,这就大大解放了我们的时间,这就是异步。
体现在网页请求上面就是我请求一个网页时候等待它回复,否则不接收其他请求,这就是同步。另一种就是我发送请求之后不去等待它是否回复,而去处理其他请求,当处理完其他请求之后,某个请求也回复了,然后程序就转而去处理这个回复数据,这就是异步请求。所以,异步可以充分发挥 CPU的效率。
6.1.3阻塞与非阻塞
调用 blocking IO会一直阻塞(block)住对应的进程直到操作完成,而non-blocking IO在kernel还在准备数据的情况下会立刻返回。
下面通过 socket实现一个命令行功能来感受一下。
#服务端
from socket import *
import subprocess
import struct
ip_port = ('127.0.0.1', 8000)
buffer_size = 1024
backlog = 5
tcp_server = socket(AF_INET, SOCK_STREAM)
tcp_server.setsockopt(SOL_SOCKET,SO_REUSEADDR,1) #就是它,在bind前加
tcp_server.bind(ip_port)
tcp_server.listen(backlog)
while True:
conn, addr = tcp_server.accept()
print('新的客户端链接:', addr)
while True:
try:
cmd = conn.recv(buffer_size)
print('收到客户端命令:', cmd.decode('utf-8'))
#执行命令cmd,得到命令的结果cmd_res
res = subprocess.Popen(cmd.decode('utf-8'),shell=True,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
stdin=subprocess.PIPE,
)
err = res.stderr.read()
if err:
cmd_res = err
else:
cmd_res = res.stdout.read()
if not cmd_res:
cmd_res = '执行成功'.encode('gbk')
#解决粘包
length = len(cmd_res)
data_length = struct.pack('i',length)
conn.send(data_length)
conn.send(cmd_res)
except Exception as e:
print(e)
break
conn.close()
#客户端
from socket import *
ip_port = ('127.0.0.1',8000)
buffer_size = 1024
backlog = 5
tcp_client = socket(AF_INET,SOCK_STREAM)
tcp_client.connect(ip_port)
while True:
cmd = input('>>:').strip()
if not cmd:
continue
if cmd == 'quit':
break
tcp_client.send(cmd.encode('utf-8'))
#解决粘包
length = tcp_client.recv(4)
length = struct.unpack('i',length)[0]
recv_size = 0
recv_msg = b''
while recv_size < length:
recv_msg += tcp_client.recv(buffer_size)
recv_size = len(recv_msg)
print(recv_msg.decode('gbk'))
运行程序时,发现服务器迟迟没有响应,这是因为当一个客户端在请求没结束时,服务器不会去处理其他客户端的请求,这时候就阻塞了。如何让服务器同时处理多个客户端请求呢?
#服务端
import socketserver
class Myserver(socketserver.BaseRequestHandler):
"""socketserver内置的通信方法"""
def handle(self):
print('conn is:',self.request) #conn
print('addr is:',self.client_address) #addr
while True:
try:
#发消息
data = self.request.recv(1024)
if not data:break
print('收到的客户端消息是:',data.decode('utf-8'),self.client_address)
#发消息
self.request.sendall(data.upper())
except Exception as e:
print(e)
break
if __name__ == '__main__':
s = socketserver.ThreadingTCPServer(('127.0.0.1',8000), Myserver) #通信循环
# s = socketserver.ForkingTCPServer(('127.0.0.1',8000), Myserver) #通信循环
print(s.server_address)
print(s.RequestHandlerClass)
print(Myserver)
print(s.socket)
s.serve_forever()
#客户端
from socket import *
ip_port = ('127.0.0.1',8000)
buffer_size = 1024
backlog = 5
tcp_client = socket(AF_INET,SOCK_STREAM)
tcp_client.connect(ip_port)
while True:
msg = input('>>:').strip()
if not msg:continue
if msg == 'quit':break
tcp_client.send(msg.encode('utf-8'))
data = tcp_client.recv(buffer_size)
print(data.decode('utf-8'))
tcp_client.close()
输出:
('127.0.0.1', 8000)
<class '__main__.Myserver'>
<class '__main__.Myserver'>
<socket.socket fd=452, family=AddressFamily.AF_INET, type=SocketKind.SOCK_STREAM, proto=0, laddr=('127.0.0.1', 8000)>
这段代码通过 socketserver 模块实现了socket的并发。在这个过程中,当一个客户端在向服务器提出请求的时候,另一个客户端也可以正常提出请求。服务器在处理一个客户端请求的时候,另一个请求也没有被阻塞。