⭐ 简介:大家好,我是zy阿二,我是一名对知识充满渴望的自由职业者。
☘️ 最近我沉溺于Python的学习中。你所看到的是我的学习笔记。
❤️ 如果对你有帮助,请关注我,让我们共同进步。有不足之处请留言指正!
A:那我们以前写的程序难道都是单线程的嘛?
Q:是的。把程序比作一个作坊。 单线程就是老板自己接单,自己安排任务,自己生产产品,自己销售。生产效率低,产值低,但是管理方便自己管自己,做完一个做下一个。
A:那多线程是什么样子?
Q:老板接了个大单子,一个人来不及干了,怎么办? 只能招工、请外援。老板从第一生产线退居到了第二生产线的管理者,管理工人生产。生产效率高了,产值也高了,但是带来问题就是如何把大量的工作合理的安排给工人呢? 请往下阅读。
A: 现有多线程教程很多,你为什么还要写他?
Q:正如标题所述,自动分配线程对应多任务。在上看到太多的文章都是直接3行代码开多线程。第一句for循环,第二句创建线程任务,第三句t.strat。殊不知这样是在用每一个线程做一个任务。过度耗费CPU资源,过高的线程并发,这样的爬虫对目标网站也是不道德的。
pip install theading
import threading
threading
模块的方法:代码 | 作用 |
---|---|
t=threading.Thead(target=func,args=(a,)) |
创建一个线程对象,并给一个func任务 |
t.start |
激活多线程对象(激活 ≠ 开启) |
t.join |
等待线程结束 |
threading.active_count() |
返回当前激活线程数 |
m=threading.BoundedSemaphore(3) |
设定线程的最大数量 |
m.acquire(timeout=5) |
超线程上锁,超时时间5秒 |
m.release() |
解锁一个线程,写在任务结束的地方 |
先来看看示例代码,单线程的。等于只有老板自己一个人在干活。生产一个商品需要耗费0.3秒,所以生产10个需要3秒。这是单线程的效果
较真的朋友要说:不对啊,明明是3.091秒啊。。。 Python编译代码,开启进程执行代码都需要时间。老板安排工作是有耗时的
import threading
import time
def func(i, ):
print(f'子线程打印{i}')
time.sleep(1)
if __name__ == '__main__':
# args接受的必须是一个可迭代对象。所以及时只有一个参数也要写args=(i,)
st = time.time()
for i in range(10):
t = threading.Thread(target=func, args=(i,))
t.start()
print('主线程结束,耗时:', time.time() - st)
老板招了10个工人,每个工人生产1个产品需要1秒。10个线程同时开始工作,任务。只0.0019秒? 聪明的小伙伴就开始提问了:
A1:就算10个人一起开始,那也应该需要1秒才能完成任务啊。为什么只用0.19秒就打印了主线程结束呢?
Q:老板给10个工人安排任务用了0.0019秒,所以老板是主线程,他没有其他任务所以结束的很快,但是子线程(工人)任然需要继续工作,1秒后,子线程全部完工,同时进程结束。所以此时的工厂有11个人,10个工人和1个老板。
A2:为什么打印的结果不是那么整齐?
Q:t.start() 是激活线程,具体开始时间取决于CPU,先激活的线程不一定就是先完成的。同时每个线程在实际情况中遇到的情况不同,所以具体完成的时间不同,打印结果也就会乱。
A3:那如果我有9999个产品难道要开9999个线程才可以吗?
Q:厉害!能想到这个问题。很多刚接触多线程去做爬虫的伙伴,经常会这样: 有100页面要爬,然后写多线程的时候代码如下
for i in range(100):
t = threading.Thread(target=func, args=(i,))
t.start()
细品这个代码是什么意思? 开了100个线程?做100个任务?
如果你是老板,你会雇佣100个工人每个工人只生成一个产品就下班了?
所以如何使用theading模块合理安排多线程多任务,请往下看。
A4:示例func中为什么没有return呢?那如何接受返回值?
Q:threading库并没有返回值的功能。所以我们要用其他的方法,1. 写入硬盘。 2. 全局变量。 3. 队列。 这也是本文要讲的内容之一。
# 先来看下 threading.Thread 中接受的参数
t = threading.Thread(group=None, target=(), name=None,
args=(), kwargs={}, *, daemon=True)
参数名 | 作用 |
---|---|
target | 必填,函数名或方法名。 |
args | 元组类型数据传参。(单个参数也需要写成元组,如:(1,)) |
kwargs | 字典类型数据传参。 |
name | 线程名,可以忽略,一般不用设置。有默认名。 |
group | 线程组,直接忽略,因为目前只能使用None。 |
daemon | 布尔值,默认False。主线程守护。True = 子线程会随主线程一起结束 |
t.setDaemon(True) | 也可以在后续设置线程守护 |
方法 | 作用 |
---|---|
t.start() | 激活线程 |
t.jion() | 等待对象线程结束。 |
threading.current_thread() | 获取当前的线程名字 |
threading.active_count() | 获得当前激活的线程数 |
lock = threading.BoundedSemaphore() | 限制最大线程数量锁 |
lock.acquire() | 上锁 |
lock.release() | 解锁 |
lock2 = threading.Lock() | 线程锁,互斥锁 |
lock2.acquire() | 上锁 |
lock2.release() | 解锁 |
不废话,直接行代码
import threading
import time
def func():
time.sleep(0.3)
print('当前线程数量:', threading.active_count())
# 在完成工作后,解锁
lock.release()
if __name__ == '__main__':
# 创建一个允许最大激活线程数量为 5 的锁
# 可以理解为:做多允许出现 5 把锁
lock = threading.BoundedSemaphore(5)
for i in range(100):
# 每次开启线程前,加一次锁,循环5次后,这里就会等待解锁一把后才会放行。
lock.acquire()
t = threading.Thread(target=func)
t.start()
妙不妙?
其实这个问题因为有更好的解决方案:线程池,所以导致了threading模块的这个控制最大线程的方法被雪藏。我上培训机构的老师都没教。都是直接一个for循环到底每个任务一个线程。
我也是钻了牛角看了很多文章,突然豁然开朗。如下是我的解题经历:
# 这个代码是我的解题经历,不是最终答案。最优解在上面
# 这个代码是我的解题经历,不是最终答案。最优解在上面
# 这个代码是我的解题经历,不是最终答案。最优解在上面
import threading
import time
def func():
time.sleep(0.3)
print('当前线程数量:', threading.active_count())
if __name__ == '__main__':
for i in range(100):
# 在主线程上加入while 判断线程数量堵塞主线程创建子线程
while threading.active_count() > 5:
if threading.active_count() < 5:
break
time.sleep(0.05)
t = threading.Thread(target=func)
t.start()
很显然这根本处理不了大数据,而且效率低下。
import threading
def func(f, i):
f.write(i)
if __name__ == '__main__':
f = open('xxx.txt', 'a')
for i in range(5):
t = threading.Thread(target=func, args=(f, i))
t.start()
f.close()
当多个线程同时操作同一个全局变量的时候,数据将会变得不准确。
而且下面的代码实际上是一个单线程的。因为读数据线程虽然创建了,但是确在等待写数据的线程结束后才被激活。
# 错误的多线程代码示例。
import threading
import time
a = []
def func1():
for i in range(5):
a.append(i)
time.sleep(0.1)
print("写:", a)
def func2():
print("读:", a)
if __name__ == '__main__':
t1 = threading.Thread(target=func1) # 写数据线程
t2 = threading.Thread(target=func2) # 读数据线程
t1.start() # 写数据线程激活
t1.join() # 等待线程对象t1结束
print("激活t2,读取数据")
t2.start() # 读数据线程激活
如果我们去掉了 t1.join之后。保证2个线程可以同时进行。我们来看下代码运行结果。
同时为了更直观的反映问题,我们把a 换成int类型,函数是让2个线程分别给 a +1 一百万次
import threading
a = 0
def func1():
for i in range(1000000):
global a
a += 1
print("func1:", a)
def func2():
for i in range(1000000):
global a
a += 1
print("func2:", a)
if __name__ == '__main__':
t1 = threading.Thread(target=func1)
t2 = threading.Thread(target=func2)
t1.start()
t2.start()
第一个线程打印的结果尽然只有158.4万+??这结果变得不可控了!就是多线程操作同一个全局变量在处理大量数据时必然会出现的问题。那如何解决呢?
在t1.start()
后面加上t1.join()
确实可以解决这个问题,但问题是多线程变成了单线程。
如果t1有其他IO任务需要3秒,t2的也有其他的IO任务需要3秒,那么加了join后的整个线程就需要6秒才能完成。这就妥妥的伪多线程啊。
再来看一个示例:
# 2个线程分别对 a-1 一百万次 。 a+1 一百万次。理论结果a 应当任然等于0
import threading
a = 0
def func1():
for i in range(1000000):
global a
a -= 1 # 让a-1 一百万次
def func2():
for i in range(1000000):
global a
a += 1 # 让a+1 一百万次
if __name__ == '__main__':
t1 = threading.Thread(target=func1)
t2 = threading.Thread(target=func2)
t1.start()
t2.start()
t2.join() # 堵塞主线程,等待t2结束后打印a的值
print(a) # 减一百万再加一百万,理论答案应该还是0
但是实际答案确出乎意料。。这就是多线程操作同一个全局变量的问题。那么下面来讲解决方案。
lock = threading.Lock()
程序开始前创建一把锁
lock.acquire()
在修改全局变量时先用此命令上锁
lock.release()
修改结束后,再加上此命令解锁
import threading
a = 0
lock = threading.Lock() # 创建锁
def func1():
lock.acquire() # 在处理数据前上锁
for i in range(1000000):
global a
a += 1
print("func1:", a)
lock.release() # 处理完了就解锁
def func2():
lock.acquire() # 处理数据时上锁
for i in range(1000000):
global a
a += 1
print("func2:", a)
lock.release() # 处理完了就解锁
if __name__ == '__main__':
t1 = threading.Thread(target=func1)
t2 = threading.Thread(target=func2)
t1.start()
t2.start()
那为了解决上面数据不可控的情况,我们利用lock = threading.Lock()
通过创建锁,上锁,解锁的步骤,解决了多线程和处理全局变量的问题。但是很显然,这样做的优势是可以做到多线程,及时t1,t2,都有3秒的IO任务,那么整个进程也是只需要3秒就会完成。但是在处理全局变量时,依然会出现t2等待t1计算结束后t2才会处理。那么到底如何才能完美解决多线程数据交互的问题呢?
标准流程第一步,安装库 :pip install queue
队列就是仓库。举个栗子,还是那个工厂,工人们各自生产产品互不影响,但是成品需要放到一个共有的仓库,等待老板下令发货,队列就是这个仓库。而仓管也有发货顺序的。现在我们来看下3个常用的queue列队的发货顺序:
模块 | 发货顺序 |
---|---|
queue.Queue |
先进先出 FIFO |
queue.LifoQueue |
后进先出 |
queue.PriorityQueue |
自定义进出顺序 |
queue.SimpleQueue |
简单的FIFO 队列,缺少任务跟踪等高级功能。 |
常用命令 | 作用 |
---|---|
q.put(x) |
添加x到队列中,x可以是任何类型数据,但是一次只能加1个数据 |
q.put(x,block=False) |
当列队已满时再增加数据会报错 queue.Full |
q.put(x,timeout=5) |
当队列已满时,会最多等待5秒,如果5秒后还是没有空位,则会报错,queue.Full |
q.get() |
从队列中取数据(得到的数据由发货顺序决定) |
q.get(block=False) |
队列为空,仍然继续取数据,会报错_queue.Empty |
q.get(timeout=5) |
取数据时可以最多等待5秒,如果5秒后仍然没数据则报错_queue.Empty |
q.qsize() |
返回队列已有数据量,int |
q.empty() |
返回队列是否为空,空为True |
q.full() |
返回列队是否已满,满为True |
q.task_done() |
告诉队列,该任务已处理完成 |
q.join |
阻塞队列。当队列添加新数据时,任务 +1,当调用task_done() ,任务 -1,当计数=0 join() 解除阻塞 |
q.queue |
得到当前队列中的所有数据 |
参数:maxsize = int,用于设置可以放入队列的数据上线。当达到这个大小的时候,插入操作将阻塞至队列中的项目被消费掉。如果 maxsize 默认 等于零,队列则为无穷大。(解释:maxsize 是设置仓库的大小,可以容纳多少商品,当仓库塞满后,后面要加进来的商品就会在仓库外面排队,有空间了才会再进来。)
import queue
q = queue.Queue() # 创建队列,不设置 maxsize,默认无穷大
for i in range(4):
q.put(i) # 往队列中加数据
for i in range(4):
print(q.get()) # 从队列中取数据
# 加进入的顺数是0、1、2、3,取出来的顺序也是0、1、2、3
0
1
2
3
import queue
q = queue.LifoQueue()
for i in range(4):
q.put(i)
for i in range(4):
print q.get()
# 加进入的顺数是0、1、2、3,取出来的顺序是3、2、1、0
3
2
1
0
import queue
# 示例1 。 正常添加到队列中。
q = queue.PriorityQueue()
q.put_nowait((0, '123', ['aaa', 'eee'], 0))
q.put_nowait((0, '456', ['bbb'], 0))
# 示例2。 报错!
q.put_nowait((0, '123', {"name": 'aaa', "age": 12}, 0))
q.put_nowait((0, '456', {"name": 'bbb'}, 0))
示例2报错内容 :
TypeError: ‘<’ not supported between instances of ‘dict’ and ‘list’。
“dict”和“list” 之间无法进行数据比较。
PriorityQueue的正确使用方式,应该是如下两种,使用tuple的第一个元素作为优先级数字,或者自定义类中重定义__lt__方法,使得类实例能够相互比较。
import queue
# 示例3。插入的tuple中,index=0的值代表优先级,index=1的值是数据
q = queue.PriorityQueue()
q.put_nowait((0, {'name': 'aaa'}))
q.put_nowait((1, {'name', 'bbbb'}))
# 示例4:
import queue
class Task(object):
def __init__(self, priority, name):
self.priority = priority
self.name = name
def __str__(self):
return f'Task(priority={self.priority}, name={self.name})'
def __lt__(self, other):
""" 定义<比较操作符。 """
return self.priority < other.priority
q = queue.PriorityQueue()
# 自定义的类定义了__lt__, 可以比较大小
q.put_nowait(Task(3, "task1"))
q.put_nowait(Task(1, "task2"))
print(q.get())
print(q.get())
返回结果:
Task(priority=1, name=task2)
Task(priority=3, name=task1)
获取堆糖网图片
https://www.duitang.com/search/?kw=%E7%BE%8E%E5%A5%B3&type=feed
第一步、获取图片信息
第二步、下载图片
代码中有超详细的注释。请直接copy代码到IDE中查看或运行。
import time
import requests # 网络请求库
import threading # 多线程库
from queue import Queue # 先进先出的队列
from tqdm import tqdm # 进度条库
import re # 正则表达式
import os
"""
获取堆糖网美女图片
https://www.duitang.com/search/?kw=%E7%BE%8E%E5%A5%B3&type=feed
"""
q = Queue() # 实例化一个队列,不指定最大长度。即无限长。
def GetImgUrl(page):
"""
生产者的实际工作内容。生产每个网页上的图片信息。 URL和给图片命名的信息传入q 队列
:param page: int, 需要爬取多少页的图片信息
"""
param = {'kw': '美女',
'after_id': str(24 * page), # 一页24条图片数据,所以这里的值是 24*page
'type': 'feed',
'include_fields': 'top_comments,is_root,source_link,item,buyable,root_id,status,like_count,like_id,sender,album,reply_count,favorite_blog_id',
'_type': '',
'_': f'{timeint}{100 + page}'} # 时间戳 + 最后3位数100是随便给的,只要随着翻页递增即可。
url = f"https://www.duitang.com/napi/blogv2/list/by_search/"
# 返回的结果中包含了我们需要下载的图片地址
resp = requests.get(url, params=param, headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36'})
# 预创建一个正则表达式,取图片地址、 ID 和 上传者的名字。后续将名字+id给图片命名
RE = re.compile(
r'"path":"(?P.*?)","size":.*?"id":(?P\d+),"sender".*?"username":"(?P.*?)"' , re.S)
img_url = RE.finditer(resp.text)
# 将 图片地址和图片ID丢到队列里
for _j in img_url:
q.put([_j.group('url'), _j.group('id'), _j.group('name')])
# tqdm.write(f"{_j.group('url')}, {_j.group('id')}, {_j.group('name')}") # 等同于print
# 完成任务,拿到图片数据,解锁一个生产者线程
ProducerMaximumThread.release()
def downloadImg(imgurl, imgid, imgname):
"""
消费者的工作,通过队列获取图片信息,并开始下载图片。
:param imgurl: str , 图片的URL地址
:param imgid: str, 图片的ID 用于给图片命名。图片名称 = imgname+imgid.jpg
:param imgname: str, 图片上传者的名字,用于给图片命名。图片名称 = imgname+imgid.jpg
:return:
"""
resp = requests.get(imgurl)
# 二进制方式写文件。 等于保存图片操作
with open(f'{pic_path}\\{imgname}{imgid}.jpg', 'wb') as f:
f.write(resp.content) # 二进制写入
# 队列任务完成,返回结果
q.task_done()
# 消费者下载图片完成,解锁一个线程
ConsumerMaximumThread.release()
def friststep():
"""
给生产者 安排任务,获取图片信息,url 和 给图片命名的数据
"""
with tqdm(range(page + 1), desc='获取图片地址') as tbar1: # 创建动作条,实例化
# 和正常循环一样,只是额外增加了进度条
for _i in tbar1:
# 设置每个循环中进度条展示的动态信息
tbar1.set_postfix(当前页码=_i, 总页数=page, 已有列队数=q.qsize(), 当前激活线程=threading.active_count())
# 上锁,限制生产者的线程数量。timeout=5 设置锁的最大时间。避免特殊情况导致堵塞
ProducerMaximumThread.acquire(timeout=5)
# 给多线程安排任务,并激活线程。
t = threading.Thread(target=GetImgUrl, args=(_i,), daemon=True)
t.start()
def secondstep():
"""
给消费者 安排任务,下载图片
"""
plan = q.qsize()
with tqdm(range(plan), desc='正在下载图片') as tbar2: # 创建进度条
# 和正常循环一样,只是额外增加了进度条
for _i in tbar2:
# 设置每个循环中进度条展示的动态信息
tbar2.set_postfix(已下载=_i, 总数=plan, 列队任务剩余=q.qsize(), 当前激活线程=threading.active_count())
# 从队列中取数据,设置超时时间,编码数据空了后直接报错
imgurl, imgid, imgname = q.get(timeout=3)
# 上锁。 限制消费者的线程数量。timeout=5 设置锁的最大时间
ConsumerMaximumThread.acquire(timeout=5)
# 给消费者多线程安排任务,并激活线程。 daemon=True 主线程结束,子线程也结束
t = threading.Thread(target=downloadImg, args=(imgurl, imgid, imgname), daemon=True)
t.start()
if __name__ == '__main__':
pic_path = r'E:\堆糖图片' # 下载图片的存放路径
# 判断文件夹 是否存在
if not os.path.exists(pic_path):
# 如果不存在那么就创建文件夹。
os.mkdir(pic_path)
# 设置需要爬多少页图片,每页24张。 控制在50以内
page = 10
# 设置生产者(爬数据)最大线程数量。获取图片URL地址的最大线程数量
ProducerMaximumThread = threading.BoundedSemaphore(3)
# 设置消费者(下载数据)最大线程数量。下载图片保存到指定文件夹
ConsumerMaximumThread = threading.BoundedSemaphore(8)
# 获取到当前时间戳,去掉小数点
timeint = int(time.time())
# 执行生产者任务,获取图片路径
friststep()
# 消费者模式,下载图片保存到指定文件夹
secondstep()