初识爬虫并发

学习并发,需要弄清楚以下5个问题:

1. 并发与并行
2. IO密集型任务和计算密集型任务
3. 同步与异步
4. IO模型(IO多路复用)
5. 内核态多线程,用户态多线程

>

所谓并发编程是指在一台处理器上“同时”处理多个任务。

并发是在同一实体上的多个事件。

强调多个事件在同一时间间隔发生

1. 进程、线程以及协程的基本概念

⑴ 进程的概念

 我们都知道计算机的核心是CPU,它承担了所有的计算任务;而操作系统是计算机的管理者,它负责任务的调度、资源的分配和管理,统领整个计算机硬件;应用程序则是具有某种功能的程序,程序是运行于操作系统之上的。

>

多道技术:空间复用+时间复用,于是有了进程!

进程是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体。

>

进程是一种抽象的概念,从来没有统一的标准定义。

进程一般由程序、数据集合和进程控制块三部分组成。

 举个例子

我和我的三个朋友的旅行故事:

我就是导航系统,我跟三个朋友一起旅行就是三个任务。

  1. 我带领第一个朋友去徒步旅行,地图就是程序,装备就是数据,我们徒步的过程就是一个进程(路径规划,位置更新)

  2. 我帮助第二个朋友学习摄影,摄影教程就是程序,相机就是数据,他学习摄影的过程就是第二个进程

  3. 我陪第三个朋友做健身训练,健身计划就是程序,健身房设备就是数据,他进行健身的过程就是第三个进程

⑵ 线程的概念

在早期的操作系统中并没有线程的概念,进程是能拥有资源和独立运行的最小单位,也是程序执行的最小单位。任务调度采用的是时间片轮转的抢占式调度方式,而进程是任务调度的最小单位,每个进程有各自独立的一块内存,使得各个进程之间内存地址相互隔离。

后来,随着计算机的发展,对CPU的要求越来越高,进程之间的切换开销较大,已经无法满足越来越复杂的程序的要求了。于是就发明了线程。

>

线程是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元,是处理器调度和分派的基本单位。

>

一个进程可以有一个或多个线程,各个线程之间共享程序的内存空间(也就是所在进程的内存空间)。一个标准的线程由线程ID、当前指令指针(PC)、寄存器和堆栈组成。而进程由内存空间(代码、数据、进程空间、打开的文件)和一个或多个线程组成。

>

简单来说:线程是由于进程占地方、时间开销大,而应运而生的。

线程的生命周期

● 创建:一个新的线程被创建,等待该线程被调用执行;
● 就绪:时间片已用完,此线程被强制暂停,等待下一个属于它的时间片到来;
● 运行:此线程正在执行,正在占用时间片;
● 阻塞:也叫等待状态,等待某一事件(如IO或另一个线程)执行完;
● 退出:一个线程完成任务或者其他终止条件发生,该线程终止进入退出状态,退出状态释放该线程所分配的资源。

进程与线程的区别

前面讲了进程与线程,但可能你还觉得迷糊,感觉他们很类似。

的确,进程与线程有着千丝万缕的关系,下面就让我们一起来理一理:

1. 线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位;
2. 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线;
3. 进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间(包括代码段、数据集、堆等)及一些进程级的资源(如打开文件和信号),某进程内的线程在其它进程不可见;
4. 调度和切换:线程上下文切换比进程上下文切换要快得多。

⑶ 协程(Coroutines)的概念

协程也可称为微线程,或非抢占式的多任务子例程,一种用户态的上下文切换技术(通过一个线程实现代码块间的相互切换执行)。

这种由程序员自己写程序来管理的轻量级线程叫做『用户空间线程』,具有对内核来说不可见的特性。正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。

协程解决的是线程的切换和内存开销的问题。

● 用户空间 首先是在用户空间, 避免内核态和用户态的切换导致的成本。
● 由语言或者框架层调度
● 更小的栈空间允许创建大量实例(百万级别)


2. 进程、线程以及协程的实现

⑴ 多线程实现

threading模块的应用

Python提供两个模块进行多线程的操作,分别是:`thread`和`threading`,前者是比较低级的模块,用于更底层的操作,一般应用级别的开发不常用。

>

threading.Thread(target, args) 是 Python 中创建线程的函数,括号里的两个参数:

target:目标函数,即需要在新线程中执行的函数

args:传递给目标函数的参数,以元组的形式传递

————————————————————————————————————————

下面比较一下串行(单线程)和并行:

import time

def foo():
    print("foo开始")
    time.sleep(2)
    print("foo结束")

def bar():
    print("bar开始")
    time.sleep(2)
    print("bar结束")

start = time.time()
#串任务开始:
foo()
bar()
print(f"串行耗时{time.time() - start}秒")

#输出:
foo开始
foo结束
bar开始
bar结束
串行耗时4.002100944519043秒
import time
import threading

def foo(t):
    print("foo开始")
    time.sleep(t)
    print("foo结束")

def bar(t):
    print("bar开始")
    time.sleep(t)
    print("bar结束")

start = time.time()
# 构建多线程并发:
t1 = threading.Thread(target=foo,args=(2,))
t1.start()   #调度线程对象

t2 = threading.Thread(target=bar,args=(2,))
t2.start()   #调度线程对象

#等所有的子线程结束后,执行打印
t1.join() #t1线程没有结束,阻塞
t2.join()



#输出:
foo开始
bar开始
bar结束foo结束

并行耗时2.0015525817871094秒





#循环执行10次foo1
def foo1(t):
    print("foo1开始")
    time.sleep(t)
    print("foo1结束")
start = time.time()

def foo1(t):
    print("foo1开始")
    time.sleep(t)
    print("foo1结束")
print(f"并行耗时{time.time() - start}秒")

#输出:
foo1开始
foo1开始
foo1开始
foo1开始
foo1开始
foo1开始
foo1开始
foo1开始
foo1开始
foo1开始
foo1结束
foo1结束
foo1结束
foo1结束
foo1结束
foo1结束
foo1结束foo1结束foo1结束foo1结束



并行耗时2.004080295562744秒
斗图网单线程下载

斗图网:斗图啦 - 斗图网 - 斗图大会 - 金馆长表情库 - 真正的斗图网站 - doutula.com

记录我犯的错误

1. with open时,括号里的path是变量,如果加上引号,则执行完不会看到图片

2. start = time.time()放在for循环下面顶格写,不知道为啥耗时显示是0.0秒

import time
import threading
import requests
from lxml import etree
import os

#斗图网单线程:
def get_img_urls():
    res = requests.get("https://www.pkdoutu.com/",
                       headers={"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
    })
    # print(res.text)
    selector = etree.HTML(res.text)
    img_urls = selector.xpath('//li[@class="list-group-item"]/div/div/a//img[@data-backup]/@data-backup')
    print(img_urls)
    print(len(img_urls))
    return img_urls

def download_img(url):

    # img_name = url.split("/")[-1]  #获取图片名称方式1
    img_name = os.path.basename(url)
    path = os.path.join("imgs",img_name)
    res = requests.get(url)  #爬取图片
    with open(path,"wb")as f:
        for i in res.iter_content():
            f.write(i)
    print(f"{img_name}下载完成!")
    
start = time.time()
#1、爬取当前页面的所有的img_url
img_urls = get_img_urls()
#2、根据img_urls下载图片:
for img_url in img_urls:
    download_img(img_url)
print(f"整体耗时{time.time() - start}秒")

斗图网实现并发下载对比
import time
import requests
from lxml import etree
import os
import threading

#斗图网并发对比
def get_img_urls():
    res = requests.get("https://www.pkdoutu.com/",
                       headers={"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
    })
    # print(res.text)
    selector = etree.HTML(res.text)
    img_urls = selector.xpath('//li[@class="list-group-item"]/div/div/a//img[@data-backup]/@data-backup')
    print(img_urls)
    print(len(img_urls))
    return img_urls

def download_img(url):

    # img_name = url.split("/")[-1]  #获取图片名称方式1
    img_name = os.path.basename(url)
    path = os.path.join("imgs",img_name)
    res = requests.get(url)  #爬取图片
    with open(path,"wb")as f:
        for i in res.iter_content():
            f.write(i)
    print(f"{img_name}下载完成!")

start = time.time()
#1、爬取当前页面的所有的img_url
img_urls = get_img_urls()

t_list = []
#2、根据img_urls下载图片:
for img_url in img_urls:

    #针对图片下载到磁盘时做多线程
    t = threading.Thread(target=download_img,args=(img_url,))
    t.start()
    t_list.append(t)
for t in t_list:
    t.join()
print(f"整体耗时{time.time() - start}秒")

执行时间:仅1.几秒:

并发模式下的互斥锁

什么是互斥锁?互斥锁是干什么用的?为什么要使用互斥锁?

第一个听到这个词,你的小脑瓜里是不是也有一堆问号O(∩_∩)O哈哈~

so,在弄清楚什么是互斥锁之前,我们先来看个有趣栗子:

串行模式下的x=100,循环100次执行x-1操作

并发模式下的y=100,循环100次执行y-1操作,但是加上time.sleep

代码如下:

 串行版本:循环100次执行x-1操作

代码无论执行多少次,结果都是0

x = 100

def sub():
    global x
    x = x - 1

#串行版本:针对x-1循环100次
for i in range(100):
    sub()
print("x:",x)


#输出:
0

 并发版本:循环100次执行y-1操作

有趣的是,代码执行多次每次都是一个不一样的结果,但不是0,结果徘徊在70~90之间

import threading
import time


#并发版本:针对y-1循环100次
y = 100

def sub():
    global y      #第一步取到y
    temp = y - 1   #第二步执行y-1
    time.sleep(0.05) #沉睡时间
    y = temp  #第三步更新y-1之后的y值

#串行版本:针对y-1循环100次(并发技术下数据的不安全性)
t_list = []
for i in range(100):
   t = threading.Thread(target=sub)
   t.start()
   t_list.append(t)
for t in t_list:
    t.join()
print("y:",y)

改为0.01秒时,结果接近100

改为0.000002时,结果接近0

当时间设定为0.05时,执行多次结果稳定在99

可以看出,time.sleep时间约大执行结果越接近100,时间约小执行结果越接近0,why?

------------------------->>>>>

可以简单理解为:

首先:这份代码就是一个程序,执行100次自减1就是100个线程

代码运行时,100个线程都想同时调度执行sub函数,每个线程都需要执行三步:

        拿到y等于100 -->>  执行100-1 -->> 将y更新为99;

        第二个线程执行之后将y更新为98,以此类推,直到100执行完毕,y更新为0;

在遇到sleep之前,100个线程都已经拿到了cup的权限,并且都已经完成了前两步部操作,得到了y为99,遇到sleep后,IO就进入到阻塞,导致x自减后还没来得及更新,就进入了等0.05秒的等待,必须等0.05秒消耗完、才会重新执行线程;

可是等0.05秒过去之后,剩下99个线程都只剩下最后一步,将y-1之后的99赋给y,所以无论执行几次都是99。(时间越长,线程苏醒越慢,减的值越小)

------------------------->>>>>

然而,sleep为0.00002是,每次执行的结果是不确定的,一直在十几/二十几等波动,这就是因为其中部分线程执行完了三步,但是剩下没跑完的线程遇到sleep后还没来得及更新y值,就被切回去了,所以结果不为0,一直在波动。(时间越短,线程苏醒越快,减的值越大)

这种现象就称之为“并发技术下数据的不安全性

------------------------->>>>>

那么为了解决这个问题,最常见的处理方式就是加锁:互斥锁

在Python中,可以使用threading模块的Lock类来实现互斥锁

互斥锁是一种同步原语,用于确保在同一时间只有一个线程可以访问共享资源。

threading.Lock()有两个方法:

lock.acquire():上锁

lock.release():释放锁

------------------------->>>>>

好,现在开始使用互斥锁,重新走一遍上面的"循环100次执行y-1操作":

 现在不论执行几次,结果都为0

但是需要注意,加锁的位置,以及释放锁的位置,很关键,加错地方会适得其反哦~~

下面的代码中:

在函数体内的业务代码的最开始进行加锁,三步走完之后开始释放锁

即一个每一个线程在跑完三步完整的步骤之前,后面的线程不会被执行

所以即便第三步之前还是有sleep也不会遇到上面没锁的情况;

加锁后执行逻辑是:第一个线程跑完前两步之后、等待0.05秒,跑完第三步,才会执行第二步程序,以此类推,直到100线程执行完毕,所以不论执行多少次结果都是0

#threading模块中的互斥锁
import threading
import time

lock = threading.Lock()

y = 100

def sub():
    lock.acquire() #加锁
    global y
    temp = y - 1
    time.sleep(0.05) #沉睡时间
    y = temp
    lock.release()  #释放锁

t_list = []
for i in range(100):
   t = threading.Thread(target=sub)
   t.start()
   t_list.append(t)
for t in t_list:
    t.join()
print("y:",y)


#输出:
0

实质上,加锁之后,相当于将数据处理部分变成了串行,它需要一个一个线程的跑,同时也影响了性能,但毕竟鱼和熊掌不能兼得。

那么是不是加锁就没有意义呢?换个角度看,是损耗了些性能,但保证了数据的准确度。

在互斥锁这点上,所有编程语言都是一样的,并不单是python的问题~

那么,在实际应用中,我们也可以在关键步骤进行加锁,以此来保证数据的一致性、安全性。


线程池

线程池的作用和意义:

系统启动一个新线程的时间成本是比较高的,因为它涉及与操作系统的交互。在这种情形下,使用线程池可以很好地提升性能,尤其是当程序中需要创建大量生存期很短暂的线程时,更应该考虑使用线程池。

>

线程池在系统启动时即创建大量空闲的线程,程序只要将一个函数提交给线程池,线程池就会启动一个空闲的线程来执行它。当该函数执行结束后,该线程并不会死亡,而是再次返回到线程池中变成空闲状态,等待执行下一个函数。

>

此外,使用线程池可以有效地控制系统中并发线程的数量。当系统中包含有大量的并发线程时,会导致系统性能急剧下降,甚至导致解释器崩溃,而线程池的最大线程数参数可以控制系统中并发线程的数量不超过此数。

 线程池示例

线程池引用:from concurrent.futures import ThreadPoolExecutor

ThreadPoolExecutor():设定同时执行的线程数

submit():启动线程

shutdown():阻塞线程

import time
from concurrent.futures import ThreadPoolExecutor
def task(i):
    print(f"任务{i}开始!")
    time.sleep(3)
    print(f"任务{i}结束!")
    return i
start = time.time()
pool = ThreadPoolExecutor(3)  #设定线程为3,表示同时最多执行3个线程

for i in range(1,11):
    pool.submit(task,i)       #启动线程

pool.shutdown()  #阻塞
print(f"耗时{time.time() - start}秒")

串行模式下的同时执行

串行还是与普通代码执行一样,即便设定了线程池数量,仍然是一个一个线程来执行的

import time
from concurrent.futures import ThreadPoolExecutor
def task(i):
    print(f"任务{i}开始!")
    time.sleep(i)
    print(f"任务{i}结束!")
    return i
start = time.time()
pool = ThreadPoolExecutor(3)  #设定线程为3,表示同时最多执行3个线程

for i in range(1,6):
   #同步实现串行:
    print(task(i))

异步并发线程池

使用线程池技术实现异步,完成并发的两个方法:

● future.result():

阻塞当前线程,直到对应的异步操作完成并返回结果;

如果异步操作还没有完成,那么这个方法会一直等待,直到操作完成为止。

如果在调用这个方法时,异步操作已经完成,那么这个方法会立即返回结果。

● future.done() :

返回布尔值,用于检查异步操作是否已经完成。

如果异步操作已经完成,那么这个方法会返回True,否则返回False。

-------------------------------------------------------------------------------------------------------------------------

简单来说:

异步编程就是通过多线程来完成 不等结果返回,

什么时候执行完,通过futur回写对象的执行结果

import time
from concurrent.futures import ThreadPoolExecutor
def task(i):
    print(f"任务{i}开始!")
    time.sleep(i)
    print(f"任务{i}结束!")
    return i
start = time.time()
pool = ThreadPoolExecutor(3)  #设定线程为3,表示同时最多执行3个线程

future_list = []
for i in range(1,4):
   #线程池技术实现异步,完成并发
    future = pool.submit(task,i)  #得到的是一个future对象
    # future.result()
    # future.done()  #返回布尔值,如果为ture,说明返回了future;false说明没有返回值
    future_list.append(future)

pool.shutdown()  #阻塞
print(f"耗时{time.time() - start}秒")
print(future_list)
print([future.result() for future in future_list])


⑵ 多进程实现

GIL锁

GIL是Global Interpreter Lock的缩写,即全局解释器锁。

由于python初创时代,计算机只有单核,为了确保数据安全,创始人加了一把锁。

它是一个互斥锁,每个线程在执行的过程中都需要先获取GIL,作用就是限制多线程同时执行, 使得在同一进程内任何时刻仅有一个线程在执行。

由于GIL的存在,在Python上开启多个线程时,这些线程会争夺GIL,导致同一时刻只有一个线程在执行,也就是说只能做到单核多线程并发,无法做到多核的多线程并行。

>
但是对于爬虫任务而言,多线程并发足够用了,只有在做大量计算任务时,会暴露性能问题

多进程

简单来说,多线程并发是把多个线程放在一个进程里去抢占一个cup,存在cup浪费,而每个进程都有一把GIL锁,所以会有性能问题。

为了解决这个问题,提升性能,可以使用多进程。

>

多进程是指在计算机系统中,同时运行多个程序的实例。每个进程都有自己独立的内存空间和系统资源,因此它们之间不会相互干扰。比如你正在运行一个游戏,同时还打开了许多软件应用,这些应用就是运行在不同的进程中,他们有各自的内存空间,互不影响。

多进程解决的是多核利用的问题,缺点是比较占空间。

多进程示例

多进程引用:import multiprocessing

multiprocessing.Process拥有和threading.Thread()同样的API

示例如下:

import time
import multiprocessing
#multiprocessing.Process拥有和threading.Thread()同样的API


def foo(t):
    print(f"任务{t}开始")
    time.sleep(t)
    print(f"任务{t}结束")

if __name__ == '__main__':
    start = time.time()
    t_list = []
    for i in range(1,5):
        t = multiprocessing.Process(target=foo,args=(i,))
        t.start()
        t_list.append(t)

    for t in t_list:
        t.join()
    print(f"耗时{time.time() - start}秒")

一秒钟一个任务,4个任务总耗时4秒多一点

⑶ 协程实现

协程,又称微线程,纤程。英文名Coroutine。

一句话说明什么是线程:协程是一种用户态的轻量级线程。

协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。

因此:

协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。

=====================================================================

协成解决的是大并发的问题。

yield与协程

yield是python中的内置关键字

主要作用是:迭代器和生成器,可以优化存储。

为什么要使用生成器?

当我们需要处理大量数据(千万级、亿级)时,如果将数据存储在列表中,会占用大量内存。

此时可以使用生成器对象来解决这个问题。

生成器对象是一种逻辑结构,它只在需要时生成数据,因此占用的内存非常小。

通过内部算法,每次只生成一个数据项,避免了一次性加载所有数据导致内存溢出的问题。

=====================================================================

生成器函数使用关键字yield而不是return来返回值,这使得它能够在每次迭代时产生一个值,并在下一次迭代时继续执行。

如下代码:

可以看出调用get_data()后,没有任何输出

打印gen得到是一个generator object(即生成器对象)

查看生成器的值可以使用:send、或者next()

def get_data():
    print("start")
    yield 1  #暂时返回,保存状态
    print('come back')
    yield 2
    print('come back2')
    yield 3

gen = get_data()
print(gen)

# gen.send()
ret  = next(gen)
print(ret)
ret  = next(gen)
print(ret)
ret  = next(gen)
print(ret)

生成器的高级用法:

#打印1~1000000万的数字:
def get_data():
    for i in range(1,1000000):
        yield i

gen = get_data()
for i in gen:
    print(i)

进程/线程/协成的区别

线程和进程都是抢占式的,抢cpu资源来执行具体任务,所以进程会存在所锁的概念

协成是分配式的,通过asyncio模块来完成分配

asyncio模块具体笔记,请见下一篇“爬虫并发 -- 进阶及应用”

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值