Python网络爬虫案例实战:并发与 Web:并发和并行、同步和异步、阻塞与非阻塞

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的并发。在这个过程中,当一个客户端在向服务器提出请求的时候,另一个客户端也可以正常提出请求。服务器在处理一个客户端请求的时候,另一个请求也没有被阻塞。
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/andyyah/article/details/141163149