100天精通Python---第37天:多线程(threading模块)

2022年05月12日 阅读数:3
这篇文章主要向大家介绍100天精通Python---第37天:多线程(threading模块),主要内容包括基础应用、实用技巧、原理机制等方面,希望对大家有所帮助。

在这里插入图片描述

每篇前言

  • 🏆🏆做者介绍:Python领域优质创做者、华为云享专家、阿里云专家博主、2021年CSDN博客新星Top6html

  • 🔥🔥本文已收录于Python全栈系列专栏《100天精通Python从入门到就业》
  • 📝​📝​此专栏文章是专门针对Python零基础小白所准备的一套完整教学,从0到100的不断进阶深刻的学习,各知识点环环相扣
  • 🎉🎉订阅专栏后续能够阅读Python从入门到就业100篇文章还可私聊进两百人Python全栈交流群(手把手教学,问题解答); 进群可领取80GPython全栈教程视频 + 300本计算机书籍:基础、Web、爬虫、数据分析、可视化、机器学习、深度学习、人工智能、算法、面试题等。
  • 🚀🚀加入我一块儿学习进步,一我的能够走的很快,一群人才能走的更远!

在这里插入图片描述
在这里插入图片描述

1、多任务介绍

现实生活中,有不少的场景中的事情是同时进行的,好比开车的时候 手和脚共同来驾驶汽车,再好比唱歌跳舞也是同时进行的;python

试想,若是把唱歌和跳舞这2件事情分开依次完成的话,估计就没有那么好的效果了(想一下场景:先唱歌,而后在跳舞,O(∩_∩)O哈哈~)
程序中web

以下程序,来模拟“唱歌跳舞”这件事情面试

#coding=utf-8

from time import sleep

def sing():
    for i in range(3):
        print("正在唱歌...%d"%i)
        sleep(1)

def dance():
    for i in range(3):
        print("正在跳舞...%d"%i)
        sleep(1)

if __name__ == '__main__':
    sing() #唱歌
    dance() #跳舞

运行结果以下:
在这里插入图片描述算法

注意浏览器

  • 很显然刚刚的程序并无完成唱歌和跳舞同时进行的要求
  • 若是想要实现“唱歌跳舞”同时进行,那么就须要一个新的方法,叫作:多任务

2、多任务的概念

什么叫“多任务”呢?简单地说,就是操做系统能够同时运行多个任务。打个比方,你一边在用浏览器上网一边在听MP3一边在用Word赶做业,这就是多任务,至少同时有3个任务正在运行。还有不少任务悄悄地在后台同时运行着,只是桌面上没有显示而已。安全

在这里插入图片描述

如今,多核CPU已经很是普及了,可是,即便过去的单核CPU,也能够执行多任务。因为CPU执行代码都是顺序执行的,那么,单核CPU是怎么执行多任务的呢?多线程

答案就是操做系统轮流让各个任务交替执行,任务1执行0.01秒,切换到任务2,任务2执行0.01秒,再切换到任务3,执行0.01秒……这样反复执行下去。表面上看,每一个任务都是交替执行的,可是,因为CPU的执行速度实在是太快了,咱们感受就像全部任务都在同时执行同样。并发

真正的并行执行多任务只能在多核CPU上实现,可是,因为任务数量远远多于CPU的核心数量,因此,操做系统也会自动把不少任务轮流调度到每一个核心上执行。app

注意

  • 并发:指的是任务数多余cpu核数,经过操做系统的各类任务调度算法,实现用多个任务“一块儿”执行(实际上总有一些任务不在执行,由于切换任务的速度至关快,看上去一块儿执行而已)
  • 并行:指的是任务数小于等于cpu核数,即任务真的是一块儿执行的

3、threading 模块介绍

Python的thread模块是比较底层的模块,python的threading模块是对thread作了一些包装的,能够更加方便的被使用

threading模块经常使用方法以下:

方法名 说明
threading.active_count() 返回当前处于active状态的Thread对象
threading.current_thread() 返回当前Thread对象
threading.get_ident() 返回当前线程的线程标识符。线程标识符是一个非负整数,并没有特殊含义,只是用来标识线程,该整数可能会被循环利用。Python3.3及之后版本支持该方法
threading.enumerate() 返回当前处于active状态的全部Thread对象列表
threading.main_thread() 返回主线程对象,即启动Python解释器的线程对象。Python3.4及之后版本支持该方法
threading.stack_size() 返回建立线程时使用的栈的大小,若是指定size参数,则用来指定后续建立的线程使用的栈大小,size必须是0(表示使用系统默认值)或大于32K的正整数

1. Thread类使用说明

threading模块提供了Thread、Lock、RLock、Condition、Event、Timer和Semaphore等类来支持多线程,Thread是其中最重要也是最基本的一个类,能够经过该类建立线程并控制线程的运行。

使用Thread建立线程的方法

  • 一、为构造函数传递一个可调用对象
  • 二、继承Thread类并在子类中重写__init__()run()方法

语法格式threading.Thread(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None)

参数说明

  • group:一般默认便可,做为往后扩展 ThreadGroup 类实现而保留。
  • target:用于 run() 方法调用的可调用对象,默认为 None。
  • name:线程名称,默认是 Thread-N 格式构成的惟一名称,其中 N 是十进制数。
  • args:用于调用目标函数的参数元组,默认为 ()。
  • kwargs:用于调用目标函数的关键字参数字典,默认为 {}。
  • daemon:设置线程是否为守护模式,默认为 None。

线程对象 threading.Thread 的方法和属性

方法名 说明
start() 启动线程。
run() 线程代码,用来实现线程的功能与业务逻辑,能够在子类中重写该方法来自定义线程的行为
init(self, group=None, target=None, name=None, args=(), kwargs=None, daemon=None) 构造函数
is_alive() 判断线程是否存活
getName() 返回线程名
setName() 设置线程名
isDaemon() 判断线程是否为守护线程
setDaemon() 设置线程是否为守护线程
name 用来读取或设置线程的名字
ident 线程标识,用非0数字或None(线程未被启动)
daemon 表示线程是否为守护线程,默认为False
join(timeout=None) 当 timeout 为 None 时,会等待至线程结束;当 timeout 不为 None 时,会等待至 timeout 时间结束,单位为秒。

2. 实例化 threading.Thread(重点)

1)单线程执行

import time


def saySorry():
    print("亲爱的,我错了,我能吃饭了吗?")
    time.sleep(1)


if __name__ == "__main__":
    for i in range(5):
        saySorry()

运行结果:
在这里插入图片描述

2)使用threading模块:

#coding=utf-8
import threading
import time

def saySorry():
    print("亲爱的,我错了,我能吃饭了吗?")
    time.sleep(1)

if __name__ == "__main__":
    for i in range(5):
        t = threading.Thread(target=saySorry)
        t.start() #启动线程,即让线程开始执行

运行结果:
在这里插入图片描述

说明

  • 能够明显看出使用了多线程并发的操做,花费时间要短不少
  • 当调用start()时,才会真正的建立线程,而且开始执行

3)主线程会等待全部的子线程结束后才结束

#coding=utf-8
import threading
from time import sleep,ctime

def sing():
    for i in range(3):
        print("正在唱歌...%d"%i)
        sleep(1)

def dance():
    for i in range(3):
        print("正在跳舞...%d"%i)
        sleep(1)

if __name__ == '__main__':
    print('---开始---:%s'%ctime())

    t1 = threading.Thread(target=sing)
    t2 = threading.Thread(target=dance)

    t1.start()
    t2.start()

    #sleep(5) # 屏蔽此行代码,试试看,程序是否会立马结束?
    print('---结束---:%s'%ctime())

运行结果:
在这里插入图片描述

4)查看线程数量

import threading
from time import sleep,ctime

def sing():
    for i in range(3):
        print("正在唱歌...%d"%i)
        sleep(1)

def dance():
    for i in range(3):
        print("正在跳舞...%d"%i)
        sleep(1)

if __name__ == '__main__':
    print('---开始---:%s'%ctime())

    t1 = threading.Thread(target=sing)
    t2 = threading.Thread(target=dance)

    t1.start()
    t2.start()

    while True:
        length = len(threading.enumerate())
        print('当前运行的线程数为:%d'%length)
        if length<=1:
            break

        sleep(0.5)

运行结果:
在这里插入图片描述

3. 继承 threading.Thread

1)线程执行代码的封装

经过上一小节,可以看出,经过使用threading模块能完成多任务的程序开发,为了让每一个线程的封装性更完美,因此使用threading模块时,每每会定义一个新的子类class,只要继承threading.Thread就能够了,而后重写run方法

示例以下:

#coding=utf-8
import threading
import time

class MyThread(threading.Thread):
    def run(self):
        for i in range(3):
            time.sleep(1)
            msg = "I'm "+self.name+' @ '+str(i) #name属性中保存的是当前线程的名字
            print(msg)


if __name__ == '__main__':
    t = MyThread()
    t.start()

运行结果:
在这里插入图片描述

说明

python的threading.Thread类有一个run方法,用于定义线程的功能函数,能够在本身的线程类中覆盖该方法。而建立本身的线程实例后,经过Thread类的start方法,能够启动该线程,交给python虚拟机进行调度,当该线程得到执行的机会时,就会调用run方法执行线程。

2)线程的执行顺序

#coding=utf-8
import threading
import time

class MyThread(threading.Thread):
    def run(self):
        for i in range(3):
            time.sleep(1)
            msg = "I'm "+self.name+' @ '+str(i)
            print(msg)
def test():
    for i in range(5):
        t = MyThread()
        t.start()
if __name__ == '__main__':
    test()

执行结果:(运行的结果可能不同,可是大致是一致的)

I'm Thread-1 @ 0
I'm Thread-2 @ 0
I'm Thread-5 @ 0
I'm Thread-3 @ 0
I'm Thread-4 @ 0
I'm Thread-3 @ 1
I'm Thread-4 @ 1
I'm Thread-5 @ 1
I'm Thread-1 @ 1
I'm Thread-2 @ 1
I'm Thread-4 @ 2
I'm Thread-5 @ 2
I'm Thread-2 @ 2
I'm Thread-1 @ 2
I'm Thread-3 @ 2

说明

从代码和执行结果咱们能够看出,多线程程序的执行顺序是不肯定的。当执行到sleep语句时,线程将被阻塞(Blocked),到sleep结束后,线程进入就绪(Runnable)状态,等待调度。而线程调度将自行选择一个线程执行。上面的代码中只能保证每一个线程都运行完整个run函数,可是线程的启动顺序、run函数中每次循环的执行顺序都不能肯定。

3)总结

  • 一、每一个线程默认有一个名字,尽管上面的例子中没有指定线程对象的name,可是python会自动为线程指定一个名字。
  • 二、当线程的run()方法结束时该线程完成。
  • 三、没法控制线程调度程序,但能够经过别的方式来影响线程调度的方式。

4. 多线程 - 共享全局变量(重点)

from threading import Thread
import time

g_num = 100

def work1():
    global g_num
    for i in range(3):
        g_num += 1

    print("----in work1, g_num is %d---"%g_num)


def work2():
    global g_num
    print("----in work2, g_num is %d---"%g_num)


print("---线程建立以前g_num is %d---"%g_num)

t1 = Thread(target=work1)
t1.start()

#延时一会,保证t1线程中的事情作完
time.sleep(1)

t2 = Thread(target=work2)
t2.start()

运行结果:

---线程建立以前g_num is 100---
----in work1, g_num is 103---
----in work2, g_num is 103---

列表当作实参传递到线程中

from threading import Thread
import time

def work1(nums):
    nums.append(44)
    print("----in work1---",nums)


def work2(nums):
    #延时一会,保证t1线程中的事情作完
    time.sleep(1)
    print("----in work2---",nums)

g_nums = [11,22,33]

t1 = Thread(target=work1, args=(g_nums,))
t1.start()

t2 = Thread(target=work2, args=(g_nums,))
t2.start()

运行结果:

----in work1--- [11, 22, 33, 44]
----in work2--- [11, 22, 33, 44]

总结

  • 在一个进程内的全部线程共享全局变量,很方便在多个线程间共享数据
  • 缺点就是,线程是对全局变量随意遂改可能形成多线程之间对全局变量的混乱(即线程非安全)

5. 多线程-共享全局变量问题

多线程开发可能遇到的问题:假设两个线程 t1 和 t2 都要对全局变量 g_num(默认是0) 进行加1运算,t1 和 t2 都各对g_num 加 10 次,g_num的最终的结果应该为20。

可是因为是多线程同时操做,有可能出现下面状况:

  • 一、在g_num=0时,t1取得g_num=0。此时系统把t1调度为”sleeping”状态,把t2转换为”running”状态,t2也得到g_num=0
  • 二、而后t2对获得的值进行加1并赋给g_num,使得g_num=1
  • 三、而后系统又把t2调度为”sleeping”,把t1转为”running”。线程t1又把它以前获得的0加1后赋值给g_num。
  • 四、这样致使虽然t1和t2都对g_num加1,但结果仍然是g_num=1

测试1:

import threading
import time

g_num = 0

def work1(num):
    global g_num
    for i in range(num):
        g_num += 1
    print("----in work1, g_num is %d---"%g_num)


def work2(num):
    global g_num
    for i in range(num):
        g_num += 1
    print("----in work2, g_num is %d---"%g_num)


print("---线程建立以前g_num is %d---"%g_num)

t1 = threading.Thread(target=work1, args=(100,))
t1.start()

t2 = threading.Thread(target=work2, args=(100,))
t2.start()

while len(threading.enumerate()) != 1:
    time.sleep(1)

print("2个线程对同一个全局变量操做以后的最终结果是:%s" % g_num)

运行结果:

---线程建立以前g_num is 0---
----in work1, g_num is 100---
----in work2, g_num is 200---
2个线程对同一个全局变量操做以后的最终结果是:200

测试2:

import threading
import time

g_num = 0

def work1(num):
    global g_num
    for i in range(num):
        g_num += 1
    print("----in work1, g_num is %d---"%g_num)


def work2(num):
    global g_num
    for i in range(num):
        g_num += 1
    print("----in work2, g_num is %d---"%g_num)


print("---线程建立以前g_num is %d---"%g_num)

t1 = threading.Thread(target=work1, args=(1000000,))
t1.start()

t2 = threading.Thread(target=work2, args=(1000000,))
t2.start()

while len(threading.enumerate()) != 1:
    time.sleep(1)

print("2个线程对同一个全局变量操做以后的最终结果是:%s" % g_num)

运行结果:

---线程建立以前g_num is 0---
----in work1, g_num is 1088005---
----in work2, g_num is 1286202---
2个线程对同一个全局变量操做以后的最终结果是:1286202

结论若是多个线程同时对同一个全局变量操做,会出现资源竞争问题,从而数据结果会不正确

6. 线程同步概念

同步就是协同步调,按预约的前后次序进行运行。若是多个线程共同对某个数据修改,则可能出现不可预料的结果,为了保证数据的正确性,须要对多个线程进行同步。

使用 Thread 对象的 LockRlock 能够实现简单的线程同步,这两个对象都有 acquire 方法和 release 方法,对于那些须要每次只容许一个线程操做的数据,能够将其操做放到 acquire 和 release 方法之间。

对于上一小节提出的那个计算错误的问题,能够经过线程同步来进行解决思路,以下:

  • 一、系统调用t1,而后获取到g_num的值为0,此时上一把锁,即不容许其余线程操做g_num
  • 二、t1对g_num的值进行+1
  • 三、t1解锁,此时g_num的值为1,其余的线程就可使用g_num了,并且是g_num的值不是0而是1
  • 四、同理其余线程在对g_num进行修改时,都要先上锁,处理完后再解锁,在上锁的整个过程当中不容许其余线程访问,就保证了数据的正确性

7. 互斥锁(重点)

当多个线程几乎同时修改某一个共享数据的时候,须要进行同步控制

线程同步可以保证多个线程安全访问竞争资源,最简单的同步机制是引入互斥锁。

互斥锁为资源引入一个状态:锁定/非锁定

某个线程要更改共享数据时,先将其锁定,此时资源的状态为“锁定”,其余线程不能更改;直到该线程释放资源,将资源的状态变成“非锁定”,其余的线程才能再次锁定该资源。互斥锁保证了每次只有一个线程进行写入操做,从而保证了多线程状况下数据的正确性。
在这里插入图片描述

threading模块中定义了Lock类,能够方便的处理锁定:

# 建立锁
mutex = threading.Lock()

# 锁定
mutex.acquire()

# 释放
mutex.release()

注意

  • 若是这个锁以前是没有上锁的,那么acquire不会堵塞
  • 若是在调用acquire对这个锁上锁以前 它已经被 其余线程上了锁,那么此时acquire会堵塞,直到这个锁被解锁为止

使用互斥锁完成2个线程对同一个全局变量各加100万次的操做:

import threading
import time

g_num = 0

def test1(num):
    global g_num
    for i in range(num):
        mutex.acquire()  # 上锁
        g_num += 1
        mutex.release()  # 解锁

    print("---test1---g_num=%d"%g_num)

def test2(num):
    global g_num
    for i in range(num):
        mutex.acquire()  # 上锁
        g_num += 1
        mutex.release()  # 解锁

    print("---test2---g_num=%d"%g_num)

# 建立一个互斥锁
# 默认是未上锁的状态
mutex = threading.Lock()

# 建立2个线程,让他们各自对g_num加1000000次
p1 = threading.Thread(target=test1, args=(1000000,))
p1.start()

p2 = threading.Thread(target=test2, args=(1000000,))
p2.start()

# 等待计算完成
while len(threading.enumerate()) != 1:
    time.sleep(1)

print("2个线程对同一个全局变量操做以后的最终结果是:%s" % g_num)

运行结果:

---test1---g_num=1909909
---test2---g_num=2000000
2个线程对同一个全局变量操做以后的最终结果是:2000000

能够看到最后的结果,加入互斥锁后,其结果与预期相符。

上锁解锁过程

  • 当一个线程调用锁的acquire()方法得到锁时,锁就进入“locked”状态。

  • 每次只有一个线程能够得到锁。若是此时另外一个线程试图得到这个锁,该线程就会变为“blocked”状态,称为“阻塞”,直到拥有锁的线程调用锁的release()方法释放锁以后,锁进入“unlocked”状态。

  • 线程调度程序从处于同步阻塞状态的线程中选择一个来得到锁,并使得该线程进入运行(running)状态。

总结

  • 锁的好处:确保了某段关键代码只能由一个线程从头至尾完整地执行

  • 锁的坏处

    阻止了多线程并发执行,包含锁的某段代码实际上只能以单线程模式执行,效率就大大地降低了
    因为能够存在多个锁,不一样的线程持有不一样的锁,并试图获取对方持有的锁时,可能会形成死锁

8. 死锁

在线程间共享多个资源的时候,若是两个线程分别占有一部分资源而且同时等待对方的资源,就会形成死锁。

尽管死锁不多发生,但一旦发生就会形成应用的中止响应。下面看一个死锁的例子:

import threading
import time

class MyThread1(threading.Thread):
    def run(self):
        # 对mutexA上锁
        mutexA.acquire()

        # mutexA上锁后,延时1秒,等待另外那个线程 把mutexB上锁
        print(self.name+'----do1---up----')
        time.sleep(1)

        # 此时会堵塞,由于这个mutexB已经被另外的线程抢先上锁了
        mutexB.acquire()
        print(self.name+'----do1---down----')
        mutexB.release()

        # 对mutexA解锁
        mutexA.release()

class MyThread2(threading.Thread):
    def run(self):
        # 对mutexB上锁
        mutexB.acquire()

        # mutexB上锁后,延时1秒,等待另外那个线程 把mutexA上锁
        print(self.name+'----do2---up----')
        time.sleep(1)

        # 此时会堵塞,由于这个mutexA已经被另外的线程抢先上锁了
        mutexA.acquire()
        print(self.name+'----do2---down----')
        mutexA.release()

        # 对mutexB解锁
        mutexB.release()

mutexA = threading.Lock()
mutexB = threading.Lock()

if __name__ == '__main__':
    t1 = MyThread1()
    t2 = MyThread2()
    t1.start()
    t2.start()

运行结果:
在这里插入图片描述
此时已经进入到了死锁状态,可使用ctrl + c退出

如何避免死锁?

  • 程序设计时要尽可能避免(银行家算法)
  • 添加超时时间等

附录-银行家算法

[背景知识]

一个银行家如何将必定数目的资金安全地借给若干个客户,使这些客户既能借到钱完成要干的事,同时银行家又能收回所有资金而不至于破产,这就是银行家问题。这个问题同操做系统中资源分配问题十分类似:银行家就像一个操做系统,客户就像运行的进程,银行家的资金就是系统的资源。

[问题的描述]

一个银行家拥有必定数量的资金,有若干个客户要贷款。每一个客户须在一开始就声明他所需贷款的总额。若该客户贷款总额不超过银行家的资金总数,银行家能够接收客户的要求。客户贷款是以每次一个资金单位(如1万RMB等)的方式进行的,客户在借满所需的所有单位款额以前可能会等待,但银行家须保证这种等待是有限的,可完成的。

例如:有三个客户C1,C2,C3,向银行家借款,该银行家的资金总额为10个资金单位,其中C1客户要借9各资金单位,C2客户要借3个资金单位,C3客户要借8个资金单位,总计20个资金单位。某一时刻的状态如图所示。
在这里插入图片描述

对于a图的状态,按照安全序列的要求,咱们选的第一个客户应知足该客户所需的贷款小于等于银行家当前所剩余的钱款,能够看出只有C2客户能被知足:C2客户需1个资金单位,小银行家手中的2个资金单位,因而银行家把1个资金单位借给C2客户,使之完成工做并归还所借的3个资金单位的钱,进入b图。同理,银行家把4个资金单位借给C3客户,使其完成工做,在c图中,只剩一个客户C1,它需7个资金单位,这时银行家有8个资金单位,因此C1也能顺利借到钱并完成工做。最后(见图d)银行家收回所有10个资金单位,保证不赔本。那麽客户序列{C1,C2,C3}就是个安全序列,按照这个序列贷款,银行家才是安全的。不然的话,若在图b状态时,银行家把手中的4个资金单位借给了C1,则出现不安全状态:这时C1,C3均不能完成工做,而银行家手中又没有钱了,系统陷入僵持局面,银行家也不能收回投资。

综上所述,银行家算法是从当前状态出发,逐个按安全序列检查各客户谁能完成其工做,而后假定其完成工做且归还所有贷款,再进而检查下一个能完成工做的客户,…。若是全部客户都能完成工做,则找到一个安全序列,银行家才是安全的。