学习笔记 爬虫篇:多线程与多进程

一、了解多线程与多进程

很多人不太了解多进程和多线程之间的关系和具体作用和用法,看网上讲解的官方概念也是云里雾里的。我们可以用现实生活中的工厂类比程序,工厂中的车间线去类比进程,车间中的工人去类比线程。

  • 一个工厂,至少有一间车间,一间车间里至少有一个工人,最终整个工厂是工人在干活。
  • 一个程序,至少有一个进程,一个进程里至少有一条线程,最终整个程序是线程在工作。

当我们执行一个.py程序的时候, 其内部会自动创建一个进程(主进程),在进程的内部自动创建一个线程(主线程)。

当工厂现阶段不足以应付源源不绝的订单需求的时候,我们可以会选择增加多个车间(但每个车间还是得至少安排一个工人,该方法资源消耗相对大)或者在车间里增加多个工人去增加工作效率。相对的,我们需要提高程序的工作效率的时候,可以选择增加多个进程(多进程),也可以选择增加多个线程(多线程)。但相对的,多进程的资源消耗比多线程要大,并且在创建多进程的时候,每个进程会自动创建一个线程,其实也还是线程在工作(就好比工厂创建多个车间,每个车间里还是都得配置工人,最终还是工人在工作)。

最后补充一下官方概念:

  • 进程是资源分配的最小单位
  • 线程是CPU调度的最小单位

 二、多线程和多进程各自的应用场景和优势

 一些人会有这样的疑问(包括我自己),既然多进程比多线程的资源消耗要大,为什么还要选多进程,我每次都用多线程不就好了?其实不然,这个问题就抛出GIL锁这个概念。

GIL,全局解释器锁,Python特有,它的作用就是让一个进程在同一时间只能有一个线程可以被CPU调用,防止了多个线程同时访问和修改Python对象,从而避免了潜在的竞态条件,确保了线程安全。然而,这也带来了明显的缺点。由于GIL的存在,Python程序无法充分利用多核处理器,导致计算密集型任务和多线程任务的性能受限。

所以因为有GIL锁的缘故,就算设置了多线程,但最终被CUP调用的只有一个线程,换言之,多线程只有一个CUP在工作,并不能发挥电脑的多核优势。

所以,如果要最大发挥电脑的多核优势同时处理一些复杂的算法或者图像处理(CUP密集型),可以设置多进程。相反,一些不太需要运用CUP的任务,比如程序中有下载、等待、读写等阻塞操作(I/O密集型)的却是可以利用多线程去有效的节省运行时间。以下载10张图片为例,在单线程的程序中,必须先下载完第一张图片才能继续下载第二张图片,假如下载一张图片的时间是1秒,那总运行时长就是10秒。但如果我们开设了10个线程,就可以让10个线程同时执行下载任务(其实同一时间真正工作的线程只有一条,当线程遇到阻塞的瞬间,CUP快速的切换到另一个线程调度,就造成多个线程同时执行的假象),当一个线程被下载任务阻塞时,另一个线程可以继续任务,不需要等待第一张图片下完才下第二张图片,这样的话总运行时长就是1秒,足足比单线程快了9秒。

所以我们可以总结出多进程和多线程各自的应用场景:

  • 多进程:适用于有大量的CUP计算的CUP密集型任务。
  • 多线程:适用于有大量的文件读写、等待、网络通信等I/O密集型任务。

多线程

多进程

三、多线程开发

3.1 threading.Thread()

import threading

def task(arg):
	print(arg)


# threading.Thread构造一个Thread对象(子线程),并封装线程被CPU调度时应该执行的任务和相关参数。
# target为子线程启动时需要调用的函数或方法
# args为传递给 `target` 函数的位置参数元组,用于传递参数给线程执行的函数

t = threading.Thread(target=task,args=('xxx',))

# t.start()表示当前线程准备就绪(等待CPU调度,具体时间是由CPU来决定)。

t.start()

print("继续执行...")  # 主线程执行完所有代码,不结束(等待子线程)

 3.2 t.start()

上面说到 t.start()表示当前线程准备就绪等待CPU调度并且调度时间是不确定的,这时程序有可能继续执行主线程,也有可能先执行子线程。下面代码可以很好说明子线程和主线程的调度顺序。

import threading

loop = 10000000
number = 0

def _add(count):
    global number
    for i in range(count):
        number += 1

t = threading.Thread(target=_add,args=(loop,))
t.start()

print(number)

 

 在上面代码中,我们用子线程执行了一个循环累加的一个函数,正常情况来说,最后输出的结果就是一千万,但在上面的运行结果来看似乎并不是,而且两次的运行结果不一样。这是因为程序执行了 t.start()之后往下走在执行主线程print(number)之前,子线程可能就已经执行了一会累加了。所以多线程之间的执行顺序是不确定的,谁先抢到资源就执行谁就先执行,可能是先执行主线程再执行子线程(即使主线程执行完毕也不会关闭,而是进行等待,等待所有的子线程执行完毕之后再关闭),也有可能是先子线程再执行主线程。所以上面的输出结果可以是0到10000000的任何一个整数。

3.3 t.join()

为了解决线程执行顺序这个问题,我们可以在t.start()后面添加t.join()

t.join(),表示等待当前子线程的任务执行完毕后再向下继续执行。

import threading

number = 0

def _add():
    global number
    for i in range(10000000):
        number += 1

t = threading.Thread(target=_add)
t.start()

t.join() # 主线程等待中...

print(number)

 

这次,程序执行了 t.start()之后,CUP开始调度子线程,程序执行到 t.join(),主线程开始等待,等待到子线程结束为止。子线程执行的函数为1到10000000次的累加,子线程执行完毕,其输出结果自然而然就是一千万了。

3.4 t.setDaemon()

前面说过即使主线程执行完毕也不会立刻关闭,而是等待所有的线程执行完毕才关闭,那么可不可以让主线程执行完毕之后自动关闭呢?答案是可以的。

  • t.setDaemon(True),设置为守护线程,主线程执行完毕之后,所有线程关闭,程序结束。
  • t.setDaemon(False),设置为非守护线程(默认),主线程执行完毕之后,主线程等待子线程关闭,子线程结束之后主线程才关闭,程序结束。
  • t.setDaemon()必须设置在t.start()之前。
import threading
import time

def task(arg):
    time.sleep(5)
    print('任务')

t = threading.Thread(target=task, args=(11,))
t.setDaemon(True) # True/False
t.start()

print('END')

3.5  自定义线程类

我们不仅可以通过上面的方法创建多线程任务,还可以自定义类去创建多线程任务,具体如下:

import requests
import threading


class DownLoadThread(threading.Thread):
    def run(self):
        file_name, video_url = self._args
        res = requests.get(video_url)
        with open(file_name, mode='wb') as f:
            f.write(res.content)


url_list = [
    ("v1.mp4", "https://xxx1"),
    ("v2.mp4", "https://xxx2"),
    ("v3.mp4", "https://xxx3")
]
for item in url_list:
    t = DownLoadThread(args=(item[0], item[1]))
    t.start()

可以看到,我们创建了三个线程去分别执行下载任务,需要注意的是:

  1. 自定义的线程类必须继承threading.Thread类。
  2. 必须重写run方法,t.start()之后会线程会调用run方法。

3.6 线程安全

一个进程中可以有多个线程,且线程共享所有进程中的资源,谁先抢到就是谁的,多个线程同时去操作一个"东西",会存在数据混乱的情况,比如:

import threading
import time

loop = 10000000
number = 0


def _add(count):
    time.sleep(2)
    global number
    for i in range(count):
        number += 1


def _sub(count):
    time.sleep(2)
    global number
    for i in range(count):
        number -= 1


t1 = threading.Thread(target=_add, args=(loop,))
t2 = threading.Thread(target=_sub, args=(loop,))
t1.start()
t2.start()
begin = time.time()
t1.join()
t2.join()
end = time.time()
print(number)
print(end-begin)

 

可以看到,上面的程序我们想要的输出的结果为0,但实际的输出结果并不是。因为这次加了t.join()所以这次并不是因为子线程还没结束就执行主线程print()了,而是因为t1和t2一直抢cup资源,这就导致t1线程的+1还没结束(算一次循环)就被t2抢掉资源开始-1了,所以结果不是0。那这种情况又要怎么去处理呢?答案是加锁。

import threading
import time

lock_object = threading.RLock()

loop = 10000000
number = 0


def _add(count):
    time.sleep(2)
    lock_object.acquire() # 加锁
    global number
    for i in range(count):
        number += 1
    lock_object.release() # 释放锁


def _sub(count):
    time.sleep(2)
    lock_object.acquire() # 加锁
    global number
    for i in range(count):
        number -= 1
    lock_object.release() # 释放锁


t1 = threading.Thread(target=_add, args=(loop,))
t2 = threading.Thread(target=_sub, args=(loop,))
t1.start()
t2.start()

begin = time.time()
t1.join()
t2.join()
end = time.time()

print(number)
print(end-begin)

加锁的作用是让多个线程抢夺锁,谁先抢到锁就会把另外被加锁“保护”的流程上锁,进入等待状态,只有第一个线程执行完释放了锁,其他被上锁的流程才能继续运行。需要注意的是,只有多个线程抢夺同一把锁才能有效。可以看到,我们通过threading.RLock()创建了一个线程锁对象lock_object,并且通过lock_object.acquire()分别在两个加减运算函数中加上了锁(都是同一把锁,ock_object),并在运算结束后释放锁。当线程抢到加运算或者减运算时,其他的运算就会被上锁,所以只能先执行加运算再执行减运算或者先进行减运算再执行加运算,所以最终的结果就是我们想要的0。

可能有人会想既然要线程不出现混乱,那可以t1.start() t1.join() t2.start() t2.join()穿插使用,这样就可以让t2等待t1运行完毕后再执行,最后的运行结果也是0。确实,这样可以防止线程混乱,但这样不就成了单线程么,加锁只是在计算部分加锁,等待部分并没有,加上计算本来就是单线程计算,所以并无大碍。可以比较上面未加锁的输出运行时间2.70秒,加锁运行时间为2.69秒,并无多大差别。

3.7 线程池 

我们在创建多线程的时候,并不是线程越多越好,线程太多反而会拖低系统的性能。如果我们要控制多线程的数量,可以使用线程池。

import time
from concurrent.futures import ThreadPoolExecutor


def task(video_url):
    print("开始执行任务", video_url)
    time.sleep(5)


# 创建线程池,参数为最大的线程数量
pool = ThreadPoolExecutor(10)

url_list = ["www.xxxx-{}.com".format(i) for i in range(300)]

for url in url_list:
    # 在线程池中提交一个任务,线程池中如果有空闲线程,则分配一个线程去执行,执行完毕后再将线程交还给线程池;如果没有空闲线程,则等待。
    pool.submit(task, url)
    # pool.submit(函数名,参数1,参数2,参数...)

print("END")

pool.shutdown()可以等待线程池中的任务执行完毕后,再继续执行,类似于t.join()。

import time
from concurrent.futures import ThreadPoolExecutor


def task(video_url):
    print("开始执行任务", video_url)
    time.sleep(5)


pool = ThreadPoolExecutor(10)

url_list = ["www.xxxx-{}.com".format(i) for i in range(300)]
for url in url_list:
    pool.submit(task, url)

print("执行中...")
pool.shutdown(True)  # 等待线程池中的任务执行完毕后,继续执行
print('继续往下走')

 pool.add_done_callback(func)线程回调,可以让线程执行任务后继续执行func函数,上个任务return的结果也可以传到func函数中。这种用法通常用于爬虫的分工任务上,比如让task执行下载任务,func执行解析或者存储任务。

import time
import random
from concurrent.futures import ThreadPoolExecutor


def task(video_url):
    print("开始执行任务", video_url)
    time.sleep(2)
    return random.randint(0, 10)


def func(response):
    print("任务执行后的返回值", response.result())


pool = ThreadPoolExecutor(10)

url_list = ["www.xxxx-{}.com".format(i) for i in range(15)]

for url in url_list:
    future = pool.submit(task, url)
    future.add_done_callback(func)

  • 15
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值