文章目录

  • 官方文档
  • 简单多线程
  • 多线程中的方法调用
  • 守护线程和join()
  • LOCK()



今天来讲一下Python的多线程,也是面试中经常考到的一个点。


在Python中,多线程是threading管理的,所以第一步:

import threading

其实写到这,后面就是去看这个库的官方文档进行学习就好了。在官方文档中,我们会看到Python多线程比较常用的几个方法。

官方文档

class threading.Thread(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None)
调用这个构造函数时,必需带有关键字参数。参数如下:
group 应该为 None;为了日后扩展 ThreadGroup 类实现而保留。
target 是用于 run() 方法调用的可调用对象。默认是 None,表示不需要调用任何方法。
name 是线程名称。默认情况下,由 "Thread-N" 格式构成一个唯一的名称,其中 N 是小的十进制数。
args 是用于调用目标函数的参数元组。默认是 ()。
kwargs 是用于调用目标函数的关键字参数字典。默认是 {}。
如果不是 None,daemon 参数将显式地设置该线程是否为守护模式。 如果是 None (默认值),线程将继承当前线程的守护模式属性。
threading.active_count()
返回当前存活的线程类 Thread 对象。返回的计数等于 enumerate() 返回的列表长度。

threading.current_thread()
返回当前对应调用者的控制线程的 Thread 对象。如果调用者的控制线程不是利用 threading 创建,会返回一个功能受限的虚拟线程对象。

threading.enumerate()
以列表形式返回当前所有存活的 Thread 对象。 该列表包含守护线程,current_thread() 创建的虚拟线程对象和主线程。它不包含已终结的线程和尚未开始的线程。

start()
开始线程活动。
它在一个线程里最多只能被调用一次。它安排对象的 run() 方法在一个独立的控制进程中调用。
如果同一个线程对象中调用这个方法的次数大于一次,会抛出 RuntimeError 。

run()#该方法自动执行
代表线程活动的方法。
你可以在子类型里重载这个方法。 标准的 run() 方法会对作为 target 参数传递给该对象构造器的可调用对象(如果存在)发起调用,并附带从 args 和 kwargs 参数分别获取的位置和关键字参数。

join(timeout=None)
等待,直到线程终结。这会阻塞调用这个方法的线程,直到被调用 join() 的线程终结 -- 不管是正常终结还是抛出未处理异常 -- 或者直到发生超时,超时选项是可选的。
当 timeout 参数存在而且不是 None 时,它应该是一个用于指定操作超时的以秒为单位的浮点数(或者分数)。因为 join() 总是返回 None ,所以你一定要在 join() 后调用 is_alive() 才能判断是否发生超时 -- 如果线程仍然存货,则 join() 超时。
当 timeout 参数不存在或者是 None ,这个操作会阻塞直到线程终结。
一个线程可以被 join() 很多次。
如果尝试加入当前线程会导致死锁, join() 会引起 RuntimeError 异常。如果尝试 join() 一个尚未开始的线程,也会抛出相同的异常。

name
只用于识别的字符串。它没有语义。多个线程可以赋予相同的名称。 初始名称由构造函数设置。

ident
这个线程的 '线程标识符',如果线程尚未开始则为 None 。这是个非零整数。参见 get_ident() 函数。当一个线程退出而另外一个线程被创建,线程标识符会被复用。即使线程退出后,仍可得到标识符。

is_alive()
返回线程是否存活。
当 run() 方法刚开始直到 run() 方法刚结束,这个方法返回 True 。模块函数 enumerate() 返回包含所有存活线程的列表。

daemon
一个表示这个线程是(True)否(False)守护线程的布尔值。一定要在调用 start() 前设置好,不然会抛出 RuntimeError 。初始值继承于创建线程;主线程不是守护线程,因此主线程创建的所有线程默认都是 daemon = False。

class threading.Lock
实现原始锁对象的类。一旦一个线程获得一个锁,会阻塞随后尝试获得锁的线程,直到它被释放;任何线程都可以释放它。
需要注意的是 Lock 其实是一个工厂函数,返回平台支持的具体锁类中最有效的版本的实例。

acquire(blocking=True, timeout=-1)
可以阻塞或非阻塞地获得锁。
当调用时参数 blocking 设置为 True (缺省值),阻塞直到锁被释放,然后将锁锁定并返回 True 。
在参数 blocking 被设置为 False 的情况下调用,将不会发生阻塞。如果调用时 blocking 设为 True 会阻塞,并立即返回 False ;否则,将锁锁定并返回 True。
当浮点型 timeout 参数被设置为正值调用时,只要无法获得锁,将最多阻塞 timeout 设定的秒数。timeout 参数被设置为 -1 时将无限等待。当 blocking 为 false 时,timeout 指定的值将被忽略。
如果成功获得锁,则返回 True,否则返回 False (例如发生 超时 的时候)。

release()
释放一个锁。这个方法可以在任何线程中调用,不单指获得锁的线程。
当锁被锁定,将它重置为未锁定,并返回。如果其他线程正在等待这个锁解锁而被阻塞,只允许其中一个允许。
在未锁定的锁调用时,会引发 RuntimeError 异常。

看到这一长串的官方文档,难免会有点枯燥无味,不如我们直接挨个实践来讲解各个用法。
首先我们需要知道定义一个线程类的方法:

t=threading.Thread(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None)

调用这个构造函数时,必需带有关键字参数。参数如下:
group是线程的分组,如果线程数量比较多且任务各有不同,可以使用该参数进行设定。
target指向一个可调用对象,一般为一个函数,也就是说明了我们的线程的任务是啥。
name 是线程名称,可自定义。默认情况下,由 “Thread-N” 格式构成一个唯一的名称,其中 N 是小的十进制数。
args 是用于调用目标函数的参数元组。默认是 ()。这里要注意的是,当元组内只有一个元素x时需要用(x,)来定义元组,不然python会认为你只是x加了个括号
kwargs 是用于调用目标函数的关键字参数字典。默认是 {}。
daemon 是守护线程的意思,定义了该线程是否为守护线程。daemon=True时,主线程执行完成后,会马上终止当前线程,就有点像咱们QQ空间的背景音乐,一旦你点进日志,就会停止播放(哈哈,有点暴露年龄内味了)。

好了我们来尝试设计一个案例:小明收集了一些形如——abc-123的“车牌号”,指向了一些mp4文件,今天他想把它们下载下来,我们先看看单线程情况下他该怎么下载。

from time import sleep
tasks = ['moive1','moive2','moive3','moive4','moive5','moive6','moive7']

def download(task):
    print(f'{task} start downloading')
    sleep(1)
    print(f'{task} finish downloading')

for task in tasks:
    download(task)

运行之后,会是这个情况。

python多线程多参数_守护线程


串行执行程序,等moive1下载完才可以开始下载moive2。显然效率有点低,我们来尝试引入多线程。

简单多线程

from time import sleep
import threading
tasks = ['moive1','moive2','moive3','moive4','moive5','moive6','moive7']

def download(task):
    print(f'{task} start downloading')
    sleep(1)
    print(f'{task} finish downloading')

for task in tasks:
    t = threading.Thread(target=download, name=task, args=(task,), daemon=None)
    t.start()

可以看到我们创建了一些Thread类,中间的属性不再解释。我们来执行一下试试。

python多线程多参数_守护线程_02


我们发现开始下载的顺序依然不变,但完成下载的顺序却有点乱。我们可以这样理解,在每一次for循环中我们都创建了一个Thread类,且立刻使其开始执行,所以开始的顺序依然不变。但是线程开始执行的下一刻我们会进入下一个循环,而不用像之前那样等待前一个线程任务执行完成才可以进入下一个循环。

多线程中的方法调用

到这里,多线程的基本使用和原理我们大致了解了,接下来我们尝试调用里面的一些方法。

from time import sleep
import threading
tasks = ['moive1','moive2','moive3','moive4','moive5','moive6','moive7']
def download(task):
    #print(f'{task} start downloading')
    sleep(1)
    print(f'download中正在执行的线程:{threading.current_thread().name}')
    #print(f'{task} finish downloading')

for task in tasks:
    t = threading.Thread(target=download, name=task, args=(task,), daemon=None)
    print(f'start前正在执行的线程:{threading.current_thread().name}')
    t.start()
    print(f'start后正在执行的线程:{threading.current_thread().name}')

for i in threading.enumerate():
    print(f'{i.name}线程的id是{i.ident},他是存活的吗?{i.is_alive()}')

print(f'存活线程数量:{threading.active_count()}')

我们执行一下来看一下:

python多线程多参数_守护线程_03


Wow,好长一串,我们来解读一下它的内容。我们发现我们在for循环中start多线程前后调用了threading.current_thread()方法,该方法用于获取当前正在执行的线程,我们发现返回的结果都是MainThread,即主线程。这是为什么呢?因为我们这条print指令是执行在主线程中的,所以不论是start前还是start后,当前执行的线程都是主线程。那我们在download函数中调用该方法试试。此时发现download中当前执行的线程为子线程,因为该方法是在子线程调用的函数中执行的。然后我们还展示了如何使用.ident、.name、.is_alive()方法。其中我们用threading.active_count()方法来统计当前存活的线程数量总和:1个主线程+7个子线程。

守护线程和join()

那么问题来了,我们下载文件的时候,在下载完我们希望返回一个任务完成的提示,那么我们怎么去实现了。我们首先尝试一下单纯把提示写在最下面。

from time import sleep
import threading
tasks = ['moive1','moive2','moive3','moive4','moive5','moive6','moive7']
def download(task):
    print(f'{task} start downloading')
    sleep(1)
    print(f'{task} finish downloading')

for task in tasks:
    t = threading.Thread(target=download, name=task, args=(task,), daemon=None)
    t.start()

print(f'我好了!')

我们执行一下试试:

python多线程多参数_主线程_04


咋回事,你咋就好了?不应该所有电影下载完才提示吗?就像我们上面说的,在for循环中我们只负责start这个线程就立刻进入下一层循环,也就是说,在我跳出for循环后,子线程很有可能还没有执行完毕,但是此时主线程会继续执行,打印“我好了!”就出现了这样的情况。那要怎么解决呢?会不会是要使用守护线程?我们试一下:

from time import sleep
import threading
tasks = ['moive1','moive2','moive3','moive4','moive5','moive6','moive7']
def download(task):
    print(f'{task} start downloading')
    sleep(1)
    print(f'{task} finish downloading')

for task in tasks:
    t = threading.Thread(target=download, name=task, args=(task,), daemon=True)
    t.start()

print(f'我好了!')

上面只是把daemon改成了True,我们执行一下试试。

python多线程多参数_守护线程_05


???这就很过分了呀,一个电影都没有下载好程序就打印完成提示了,这差的更多了。产生这种情况的原因就像我上面讲的,打印完成提示是主线程的最后一步,当所有子线程都是守护线程时,我们在执行完主线程后会立刻停止所有子线程任务,也就是终止了电影的下载,显然守护线程不是用在这里的,那应该怎么去解决呢?这里就要提到x.join()方法。你可以把x.join()方法看作是一个排队方法,即执行后当前线程需在x后执行。那我们把主线程排在所有的子线程后面不就好了嘛,我们来操作一下:

from time import sleep
import threading
tasks = ['moive1','moive2','moive3','moive4','moive5','moive6','moive7']
def download(task):
    print(f'{task} start downloading')
    sleep(1)
    print(f'{task} finish downloading')

for task in tasks:
    t = threading.Thread(target=download, name=task, args=(task,))
    t.start()


for i in threading.enumerate():
    if i.name!='MainThread':
        i.join()
print(f'我好了!')

我们执行一下试试:

python多线程多参数_主线程_06


果然这样就正常了。

LOCK()

最后我们来讲一下threading里面的一个类,LOCK()。这个类有啥作用呢,首先我们改一下我们的案例:小明现在要下10000部电影,分100个线程分别去下而且还要计数。我们来操作一下:

import threading
count = 0
moives = 10000#电影数量
threads = 100#线程数量
def download():
    global count
    for i in range(moives):
        count+=1

for i in range(threads) :
    t = threading.Thread(target=download)
    t.start()


for i in threading.enumerate():
    if i.name!='MainThread':
        i.join()
print(f'应该:{moives * threads}')
print(f'实际:{count}')

我们执行一下来看count的实际数值和理论数值是否一致。

python多线程多参数_多线程_07


我们发现是一致的,我们加大数量,假如要下载1000000部电影。

python多线程多参数_多线程_08


我们发现数量相差的非常大。这是因为在高并发状态下,所有线程一起对count这个计数值进行操作,难免会在同一时刻执行count+=1的操作,那么很可能会被计算机误以为只执行了一次。那么要怎么解决这个问题呢?我们就要用到LOCK()这个类。顾名思义它就是一把锁,在一个线程对数据进行操作之前先上锁,那么其他线程就无法对这个数据进行操作,只能等待上一个线程将锁释放后才能继续执行。我们看一下具体的实现方法:

import threading
count = 0
moives = 1000000#电影数量
threads = 100#线程数量
lock=threading.Lock()#定义一把锁
def download():
    global count
    for i in range(moives):
        lock.acquire()#取锁
        count+=1
        lock.release()#释放锁

for i in range(threads) :
    t = threading.Thread(target=download)
    t.start()


for i in threading.enumerate():
    if i.name!='MainThread':
        i.join()
print(f'应该:{moives * threads}')
print(f'实际:{count}')

我们来执行一下试试。

python多线程多参数_主线程_09


从执行到停止等了我N久,执行的速度比不加锁的速度慢了很多,因为加锁这个操作就把多线程转化为单线程了,但只限我们这个特例,因为我们的函数中只有一句count+=1。在我们正常的Python多线程开发中,我们只需要对高并发的数据或者可调用对象使用锁即可。但是最后的结果是没有问题的。

好了,今天的讲解就到这里了,是不是非常的Clever呢?请大家多多指教~~