Python 多线程

1. Python 多线程介绍

2. 创建多线程 - threading

3. 创建多线程 - Thread 子类

4. 同步

5. 死锁

6. 生产者与消费者模式

7. GIL(全局解释器锁)

 

 

1. Python 多线程介绍

1.1 什么是 Python 的多线程?

Python 的多线程是指一个进程中包含的多个执行流(可执行的计算单元),即在一个进程中可以同时运行多个不同的线程,来执行不同的任务。

注意,一个 CPU 同一时间只能执行一个线程。

好处

使用多线程的好处是可以提高 CPU 的利用率。在多线程程序中,当其中一个线程必须等待的时候,CPU 可以运行其它的线程而不是等待,这样就大大提高了程序的效率。也就是说允许单个程序创建多个并行执行的线程来完成各自的任务。

坏处

至于多线程的坏处么,主要有三点:

  1. 线程也是程序,所以线程需要占用内存。线程越多,占用内存也越多;
  2. 多线程需要协调和管理,所以需要 CPU 时间跟踪线程;
  3. 线程之间对共享资源的访问会相互影响,必须解决竞用共享资源的问题。

并发和并行

  • 并发(concurrency):多个任务在同一个 CPU 核上,按细分的时间片轮流(交替)执行,由于每个时间片的时间非常短,看起来那些任务就像是同时执行。
  • 并行(parallelism):单位时间内,多个处理器或多核处理器同时处理多个任务,是真正意义上的“同时进行”。

现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。面对复杂业务模型,并行程序会比串行程序更适应业务需求,而并发编程更能吻合这种业务拆分 。

 

1.2 (CPU)上下文切换

什么是上下文切换?

在多线程编程中,一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。

时间片是 CPU 分配给各个线程的时间,因为时间非常短,所以 CPU 不断通过切换线程,让我们觉得多个线程是同时执行的,时间片一般是几十毫秒。

概括来说就是:当前任务在执行完 CPU 时间片并切换到另一个任务之前,会先保存自己的状态,以便下次再切换回这个任务保证原来的状态不受影响,让任务看起来还是连续运行。任务从保存(旧任务状态)到加载(新任务状态)的过程就是一次上下文切换

频繁切换上下文的问题

上下文切换通常是计算密集型的,每次切换时,需要保存当前的状态起来,以便能够进行先前状态的恢复,而这个切换时非常损耗性能。

也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间。事实上,这可能是操作系统中时间消耗最大的操作。

Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。

减少上下文切换的方式有哪些?

通常减少上下文切换的方式有:

  1. 无锁并发编程:可以参照 concurrentHashMap 锁分段的思想,不同的线程处理不同段的数据,这样在多线程竞争的条件下,可以减少上下文切换的时间。
  2. CAS 算法:利用 Atomic 下的 CAS 算法来更新数据,即使用了乐观锁,可以有效的减少一部分不必要的锁竞争带来的上下文切换。
  3. 使用最少线程:避免创建不需要的线程,比如任务很少,但是创建了很多的线程,这样会造成大量的线程都处于等待状态。
  4. 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

1.3 进程 VS 线程

现代计算机的 CPU 有多个核心,有时甚至有多个处理器。为了利用所有计算能力,操作系统定义了一个底层结构,叫做线程,而一个进程(例如 Chrome 浏览器)能够生成多个线程,通过线程来执行系统指令。这样如果一个进程是要使用很多 CPU,那么计算负载就会由多个核心分担,最终使得绝大多数应用能更快地完成任务。

定义

  • 进程是静态的概念:程序进入内存,并获得了系统所分配的对应资源。因此进程是系统资源分配的基本单元;线程是动态的概念:进程创建的同时也产生了一个主线程用来执行任务,因此线程是可执行的计算单元,是 CPU 调度的基本单位。
  • 一个程序启动后至少有一个进程,一个进程至少有一个线程;线程不能够独立执行,必须依存在进程中。
  • 进程与线程均能够完成多任务。比如一台电脑上能够同时运行多个 QQ(多进程);一个 QQ 中使用多个聊天窗口(多线程)。

从内核的角度看,进程的目的就是担当分配系统资源(CPU 时间、内存等)的基本单位。线程是进程的一个执行流,是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。

资源消耗

总的来说,进程与线程最大的区别在于上下文切换过程中,线程不用切换虚拟内存,因为同一个进程内的线程都是共享虚拟内存空间的,线程就单这一点不用切换,就比进程上下文切换的性能开销(空间和时间)减少了很多。据统计,一个进程的开销大约是一个线程开销的 30 倍左右,当然,在具体的系统上,这个数据可能会有较大的区别。

由于虚拟内存与物理内存的映射关系需要查询页表,页表的查询是很慢的过程,因此会把常用的地址映射关系缓存在 TLB 里的,这样便可以提高页表的查询速度,如果发生了进程切换,那 TLB 缓存的地址映射关系就会失效,缓存失效就意味着命中率降低,于是虚拟地址转为物理地址这一过程就会很慢。

优劣势

维度多进程多线程优劣
数据共享、同步 数据是分开的;共享复杂;同步简单 多线程共享进程数据:共享简单;同步复杂 各有优势
内存、CPU 占用内存多,切换复杂,CPU 利用率低 占用内存少,切换简单,CPU 利用率高 线程占优
创建销毁、切换 创建销毁、切换复杂,速度慢 创建销毁、切换简单,速度快 线程占优
编程调试 编程简单,调试简单 编程复杂,调试复杂 进程占优
可靠性 进程间不会相互影响 一个线程挂掉将导致整个进程挂掉 进程占优
分布式 适应于多核、多机分布 ;如果一台机器不够,扩展到多台机器比较简单 适应于多核分布 进程占优

应用场景

1)需要频繁创建销毁的,优先用线程

这种原则最常见的应用就是 web 服务器了,来一个连接建立一个线程,断了就销毁线程,要是用进程,创建和销毁的代价是很难承受的。

2)需要进行大量计算的,优先使用线程

所谓大量计算,当然就是要耗费很多 CPU,切换频繁了,这种情况下线程是最合适的。

这种原则最常见的是图像处理、算法处理。

3)强相关的处理用线程,弱相关的处理用进程

什么叫强相关、弱相关?理论上很难定义,给个简单的例子就明白了。

一般的 server 需要完成如下任务:消息收发、消息处理。“消息收发”和“消息处理”就是弱相关的任务,而“消息处理”里面可能又分为“消息解码”、“业务处理”,这两个任务相对来说相关性就要强多了。因此“消息收发”和“消息处理”可以分进程设计,“消息解码”、“业务处理”可以分线程设计。

当然这种划分方式不是一成不变的,也可以根据实际情况进行调整。

4)可能要扩展到多机分布的用进程,多核分布的用线程

5)都满足需求的情况下,用你最熟悉、最拿手的方式

至于“数据共享、同步”、“编程、调试”、“可靠性”这几个维度的所谓的“复杂、简单”应该怎么取舍,其实没有明确的选择方法。有一个选择原则:如果多进程和多线程都能够满足要求,那么选择你最熟悉、最拿手的那个。

需要提醒的是:虽然有这么多的选择原则,但实际应用中基本上都是“进程+线程”的结合方式,千万不要真的陷入一种非此即彼的误区。

1.4 单核 CPU 多线程执行有没有意义?

虽然单线程全部占有 CPU,但不代表全部利用。而多线程能更好的利用资源,前提是组织好程序(比如需要执行多个不同的任务),否则并发执行的效率不一定比串行执行高,因为多线程在执行的时候会有抢占 CPU 资源、上下文切换的过程。

如果你的程序仅仅是做一种简单的计算,其间不涉及任何可能是使线程挂起的操作,如 I/O 读写,等待某种事件等,那么从表面上看,两个线程与单个线程相比,增加了切换的开销,应该比单线程慢才对。

但还得考虑操作系统的调度策略。通常,在支持线程的操作系统中,线程才是系统调度的单位,对同样一个进程来讲,多一个线程就可以多分到 CPU 时间,特别是从一个增加到两个的时候。

举例来说,假如在你的程序启动前,系统中已经有 50 个线程在运行,那么当你的程序启动后,假如他只有一个线程,那么平均来讲,它将获得 1/51 的 CPU 时间,而如果他有两个线程,那么就会获得 2/52 的 CPU 时间(当然,这是一种非常理想的情况,它没有考虑系统中原有其他线程的繁忙或者空闲程度,也没有考虑线程切换)。

但是如果你的程序里面已经有 1000 个线程,那么你把它加到 1500,效果就不会有从 1 个线程加到 2 个线程来的明显。而且很可能造成系统的整体性能下降,因为线程之间的切换也需要时间。

1.5 对于一个程序,设置多少个线程合适?(线程池设定多少核心线程?)

简单思路如下:

  1. 如何合理设置线程数,与线程占用 CPU 的时间强相关。 
  2. 首先测算一个线程中,占用 CPU 的时间是多少,不占用 CPU 的时间是多少,算出两者的比值,那么分子和分母的和即为合理的线程数,此时 CPU 利用率就能达到 100%。
  3. 比如占用 CPU 时间和不占用 CPU 时间的比值是 1:1,则应设置 2 个线程;再比如占用 CPU 时间和不占用 CPU 时间的比值是 1:3,则应设置 4 个线程。

1.6 用户线程和内核线程

在操作系统中,用户级线程和内核级线程是什么?

在操作系统的设计中,为了防止用户操作敏感指令而对 OS 带来安全隐患,OS 被分成了用户空间(user space)和内核空间(kernel space)。

用户级线程

通过用户空间的库类实现的线程,就是用户级线程(user-level threads,ULT)。这种线程不依赖于操作系统核心,进程利用线程库提供创建、同步、调度和管理线程的函数来控制用户线程。

在图里,我们可以清楚的看到,线程表(Thread table,管理线程的数据结构)是处于进程内部的,完全处于用户空间层面,内核空间对此一无所知!当然,用户线程也可以没有线程表! 

内核级线程

相应的,由 OS 内核空间直接掌控的线程,称为内核级线程(kernel-level threads,KLT)。其依赖于操作系统核心,由内核的内部需求进行创建和撤销。 

同样的,在图中,我们看到内核线程的线程表(Thread table)位于内核中,包括了线程控制块(TCB),一旦线程阻塞,内核会从当前或者其他进程(process)中重新选择一个线程保证程序的执行。

对于用户级线程来说,其线程的切换发生在用户空间,这样的线程切换至少比陷入内核要快一个数量级。但是该种线程有个严重的缺点:如果一个线程开始运行,那么该进程中其他线程就不能运行,除非第一个线程自动放弃 CPU。因为在一个单独的进程内部,没有时钟中断,所以不能用轮转调度(轮流)的方式调度线程。

这两种线程在多核 CPU的计算机上是否都能并行?

同一进程中的用户级线程,在不考虑调起多个内核级线程的基础上,是没有办法利用多核 CPU 的,其实质是并发而非并行

对于内核级线程来说,其线程在内核中创建和撤销线程的开销比较大,需要考虑上下文切换的开销。

但是,内核级线程是可以利用多核 CPU 的,即可以并行

1.7 线程池的工作原理

为了形象描述线程池执行,加深理解,打个比喻:

  • 核心线程比作公司正式员工;
  • 非核心线程比作外包员工;
  • 阻塞队列比作需求池;
  • 提交任务比作提需求。
  1. 当产品提个需求,正式员工(核心线程)先接需求(执行任务)。
  2. 如果正式员工都有需求在做,即核心线程数已满,产品就把需求先放需求池(阻塞队列)。
  3. 如果需求池(阻塞队列)也满了,但是这时候产品继续提需求,怎么办呢?那就请外包(非核心线程)来做。
  4. 如果所有员工(最大线程数也满了)都有需求在做了,那就执行饱和策略。
  5. 如果外包员工把需求做完了,他经过一段(keepAliveTime)空闲时间,就离开公司了。

线程池的饱和策略事件,主要有四种类型:

  • AbortPolicy(抛出一个异常,默认的)
  • DiscardPolicy(新提交的任务直接被抛弃)
  • DiscardOldestPolicy(丢弃队列里最老的任务,将当前这个任务继续提交给线程池)
  • CallerRunsPolicy(交给线程池调用所在的线程进行处理,即将某些任务回退到调用者)

 

2. 创建多线程 - threading

python 的 thread 模块是比较底层的模块,而 python 的 threading 模块对 thread 做了一些包装,可以更加方便的被使用。

  • 多线程并发的操作,花费时间要(比多进程)短很多。
  • 每个线程一定会有一个名字,尽管没有指定线程对象的 name,但是 python 会自动为线程指定一个名字。
  • 主线程会等待所有的子线程结束后才结束。
  • 当线程的 run() 方法结束时,该线程完成。
  • 全局变量是多个线程都共享的数据,而局部变量等是各自线程的,是非共享的。

线程的几种状态

示例 1:

 1 import threading
 2 import time
 3 
 4 
 5 def sing():
 6     for i in range(3):
 7         print("sing:%d" % i)
 8         time.sleep(1)
 9     
10     
11 def dance():
12     for i in range(3):
13         print("dance:%d" % i)
14         time.sleep(1)
15 
16     
17 if __name__ == "__main__":
18     print("---开始---")
19     t1 = threading.Thread(target=sing)
20     t2 = threading.Thread(target=dance)
21     t1.start()  
22     t2.start()
23     print("---结束---")

执行结果:

---开始---
sing:0
dance:0
---结束---
dance:1
sing:1
sing:2
dance:2

示例 2:查看线程数量

 1 import threading
 2 import time
 3 
 4 
 5 def sing():
 6     for i in range(3):
 7         print("sing:%d" % i)
 8         time.sleep(1)
 9     
10     
11 def dance():
12     for i in range(3):
13         print("dance:%d" % i)
14         time.sleep(1)
15 
16 # 主线程+2个start()线程,共会执行3个线程     
17 if __name__ == "__main__":
18     print("---开始---")
19     t1 = threading.Thread(target=sing)
20     t2 = threading.Thread(target=dance)
21     t1.start()  
22     t2.start()
23     
24     while True:
25         length = len(threading.enumerate())
26         print("当前运行的线程数为:%s" % length)
27         if length <= 1:
28             break
29         time.sleep(1)

执行结果:

---开始---
sing:0
dance:0
当前运行的线程数为:3
sing:1
dance:1
当前运行的线程数为:3
当前运行的线程数为:3
dance:2
sing:2
当前运行的线程数为:2
当前运行的线程数为:1

 

3. 创建多线程 - Thread 子类

通过使用 threading 模块能完成多任务的程序开发。为了让每个线程的封装性更完美,在使用 threading 模块时,往往会定义一个新的子类(继承 threading.Thread),然后重写 run 方法。

Thread 类中的 run 方法,用于定义线程的功能函数,在创建自己了线程实例后,通过 Thread 类的 start 方法,可以启动该线程,交给 python 虚拟机进行调度,当该线程获得执行的机会时,就会调用 run 方法执行线程。

示例:

 1 import threading
 2 import time
 3 
 4 
 5 class MyThread(threading.Thread):
 6 
 7     def run(self):
 8         for i in range(3):
 9             time.sleep(1)
10             msg = "I'm "+self.name+""+str(i)  # name属性保存的是当前线程的名称
11             print(msg)
12             
13             
14 if __name__ == "__main__":
15     t = MyThread()
16     t.start()

执行结果:

I'm Thread-1:0
I'm Thread-1:1
I'm Thread-1:2

多线程的执行顺序

 1 import threading
 2 import time
 3 
 4 
 5 class MyThread(threading.Thread):
 6 
 7     def run(self):
 8         for i in range(3):
 9             time.sleep(1)
10             msg = "I'm"+self.name+""+str(i)  # name属性保存的是当前线程的名称
11             print(msg)
12             
13             
14 if __name__ == "__main__":
15     for i in range(7):
16         t = MyThread()
17         t.start()

执行效果:

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

I'mThread-1:0
I'mThread-2:0
I'mThread-3:0
I'mThread-4:0
I'mThread-6:0
I'mThread-5:0
I'mThread-7:0
I'mThread-1:1
I'mThread-2:1
I'mThread-3:1
I'mThread-4:1
I'mThread-6:1
I'mThread-5:1
I'mThread-7:1
I'mThread-1:2
I'mThread-2:2
I'mThread-3:2
I'mThread-4:2
I'mThread-6:2
I'mThread-5:2
I'mThread-7:2

 

4. 同步

多线程开发可能遇到的问题

假设两个线程 t1 和 t2 都要对 num=0 进行增 1 运算,t1 和 t2 都各对 num 修改 10 次,num 的最终的结果应该为 20。

但是由于是多线程访问,有可能出现下面情况:

  1. 在 num=0 时,t1 取得 num=0。
  2. 此时系统把 t1 调度为”sleeping”状态,把 t2 转换为”running”状态,t2 也获得 num=0。于是 t2 对得到的值进行加 1 并赋给 num,使得 num=1。
  3. 接着系统又把 t2 调度为”sleeping”,把 t1 转为”running”。线程 t1 又把它之前得到的 0 加 1 后赋值给 num。
  4. 于是,明明 t1 和 t2 都完成了 1 次加 1 工作,但结果仍然是 num=1。

问题产生的原因就是没有控制多个线程对同一资源的访问,对数据造成破坏,使得线程运行的结果不可预期。这种现象称为“线程不安全”。

什么是同步

同步就是协同步调,按预定的先后次序进行运行。如:你说完,我再说。

"同"字从字面上容易理解为一起动作,其实不是,"同"字应是指协同、协助、互相配合。

如进程、线程同步,可理解为进程或线程 A 和 B 一块配合,A 执行到一定程度时要依靠 B 的某个结果,于是停下来,示意 B 运行;B 依言执行,再将结果给 A;A 再继续操作。

BIO、NIO、AIO 的区别

  • 同步阻塞(blocking-IO)简称 BIO;
  • 同步非阻塞(non-blocking-IO)简称 NIO;
  • 异步非阻塞(asynchronous-non-blocking-IO)简称 AIO。

一个生活的例子

  • 小明去吃同仁四季的椰子鸡,就这样在那里排队,等了一小时,然后才开始吃火锅。(BIO)
  • 小红也去同仁四季的椰子鸡,她一看要等挺久的,于是去逛会商场,每次逛一下,就跑回来看看是不是轮到她了。于是最后她既购了物,又吃上椰子鸡了。(NIO)
  • 小华一样,去吃椰子鸡,由于他是高级会员,所以店长说,你去商场随便逛会吧,等下有位置,我立马打电话给你。于是小华不用干巴巴坐着等,也不用每过一会儿就跑回来看有没有等到,最后也吃上了美味的椰子鸡。(AIO)

互斥锁

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

线程同步能够保证多个线程安全访问竞争资源,最简单的同步机制是引入互斥锁。互斥锁为资源引入了一个状态:锁定/非锁定。

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

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

#创建锁
mutex = threading.Lock()
#锁定
mutex.acquire([blocking])
#释放
mutex.release()

其中,锁定方法 acquire 可以有一个 blocking 参数:

  • 如果设定 blocking 为 True,则当前线程会堵塞,直到获取到这个锁为止(如果没有指定,那么默认为 True)。
  • 如果设定 blocking 为 False,则当前线程不会堵塞。

示例:

 1 from threading import Thread, Lock
 2 
 3 g_num = 0
 4 
 5 def test_1():
 6     global g_num
 7     for i in range(100000):
 8         # 默认True表示堵塞,即如果这个线程在上锁之前已经被另一方上锁了,那么这个线程会一直等待到解锁为止
 9         # False表示非堵塞,即不管能否成功获得锁,都不会等待,直接跳过加锁块中的代码往下执行
10         if mutex.acquire(True):
11             g_num += 1
12         mutex.release()
13     print("---test1---g_num=%d" % g_num)
14 
15 
16 def test_2():
17     global g_num
18     for i in range(100000):
19         if mutex.acquire(True):
20             g_num += 1
21         mutex.release()
22     print("---test2---g_num=%d" % g_num)
23 
24 
25 # 创建一个互斥锁,默认是未上锁状态
26 mutex = Lock()
27 p1 = Thread(target=test_1)
28 p1.start()
29 p2 = Thread(target=test_2)
30 p2.start()
31 
32 print("final g_num = %d" % g_num)  # 主线程

执行结果:

final g_num = 5334
---test1---g_num=195684
---test2---g_num=200000

上锁解锁过程

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

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

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

等待解锁的方式为“通知”机制,即一个线程释放锁后会通知其他线程获得锁。相比“轮询”机制来说效率更高(轮询是指不断判断条件是否满足)。

总结

锁的好处:

  • 确保了某段关键代码只能由一个线程从头到尾完整地执行。

锁的坏处:

  • 阻止了多线程并发执行,包含锁的某段代码实际上只能以单线程模式执行,效率就大大地下降了。
  • 由于可以存在多个锁,不同的线程持有不同的锁,并试图获取对方持有的锁时,可能会造成死锁。

范例 1:同步的应用

可以使用互斥锁使得多个线程有序地执行任务,这就是线程的同步。

 1 from threading import Thread, Lock
 2 import time
 3 
 4 
 5 class Task1(Thread):
 6     def run(self):
 7         while True:
 8             if lock1.acquire():  # 一开始只有lock1未上锁
 9                 print("Task1")
10                 time.sleep(0.5)
11                 lock2.release()  # 释放lock2
12 
13 
14 class Task2(Thread):
15     def run(self):
16         while True:
17             if lock2.acquire():  # 一开始已被主线程上锁
18                 print("Task2")
19                 time.sleep(0.5)
20                 lock3.release()  # 释放lock3
21 
22 
23 class Task3(Thread):
24     def run(self):
25         while True:
26             if lock3.acquire():  # 一开始已被主线程上锁
27                 print("Task3")
28                 time.sleep(0.5)
29                 lock1.release()  # 释放lock1             
30                            
31 
32 if __name__ == "__main__":
33     
34     lock1 = Lock()
35     lock2 = Lock()
36     lock3 = Lock()
37     # 只有lock1未上锁
38     lock2.acquire()
39     lock3.acquire()
40     t1 = Task1()
41     t2 = Task2()
42     t3 = Task3()
43     t1.start()
44     t2.start()
45     t3.start()

运行结果:

task1
task2
task3
task1
task2
task3
task1
# 不断有序执行

范例 2:单例模式的线程安全问题

未加锁版的单例模式,在多线程下,可能会存在线程安全问题,即创建了多个实例。

 1 class Singleton:
 2 
 3     __instance = None
 4     # 如果__instance没有被赋值过(即为None),
 5     # 那么就创建一个对象,并且赋值为这个对象的引用,保证下次调用这个方法时
 6     # 能够知道之前已经创建过对象了,这样就保证了只有1个对象
 7 
 8     def __new__(cls):
 9         if not cls.__instance:  # 此处有可能发生多个线程同时判断了False,从而各自创建实例对象
10             cls.__instance = object.__new__(cls)
11         return cls.__instance

改进:DCL(Double Check Lock,双重检查锁)单例模式

此加锁版的单例模式,可以避免线程安全问题。

 1 class Singleton:
 2 
 3     __instance = None
 4     # 如果__instance没有被赋值过(即为None),
 5     # 那么就创建一个对象,并且赋值为这个对象的引用,保证下次调用这个方法时
 6     # 能够知道之前已经创建过对象了,这样就保证了只有1个对象
 7 
 8     def __new__(cls):
 9         if not cls.__instance:  # 此处的判断,多线程间可各自执行判断,无需争夺锁资源,因此耗时纳秒级别
10             lock.acquire():
11                 if not cls.__instance:  # 如果没有外层的if判断,则每次判断都需要各个线程先争夺锁,耗时在微秒级别
12                     cls.__instance = object.__new__(cls)
13                     lock.release()
14         return cls.__instance

 

5. 死锁

死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。

 

假设你要开车进入一个村子,村口有条非常窄的路,只能容纳一辆车过。这时候,迎面又驶来一辆车,你们都走到一半,谁也不想倒回去,于是各不相让,陷入无尽的等待。

尽管死锁很少发生,但一旦发生就会造成应用的停止响应。

示例:

 1 from threading import Thread, Lock
 2 import time
 3 
 4 
 5 class MyThread1(Thread):
 6 
 7     def run(self):
 8         if mutextA.acquire():
 9             print(self.name + "1 first")
10             time.sleep(1)
11             
12             if mutextB.acquire():
13                 print(self.name + "1 second")
14                 mutextB.release()
15             
16             mutextA.release()
17         
18         
19 class MyThread2(Thread):
20 
21     def run(self):
22         if mutextB.acquire():
23             print(self.name + "2 first")
24             time.sleep(1)
25             
26             if mutextA.acquire():
27                 print(self.name + "2 second")
28                 mutextA.release()
29             
30             mutextB.release()        
31         
32         
33 if __name__ == "__main__":    
34     mutextA = Lock()
35     mutextB = Lock()
36     t1 = MyThread1()
37     t2 = MyThread2()
38     t1.start()
39     t2.start()

执行结果:程序无法往下执行,一直卡着

Thread-11 first
Thread-22 first

图示死锁现象

避免死锁的方法

  • 程序设计时要尽量避免(银行家算法)。
  • 添加超时时间等,如在 mutex.acquire() 中添加超时时间参数。

附录-银行家算法

[背景知识]

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

[问题描述]

一个银行家拥有一定数量的资金,有若干个客户要贷款。每个客户须在一开始就声明他所需贷款的总额。若该客户贷款总额不超过银行家的资金总数,银行家可以接收客户的要求。客户贷款是以每次一个资金单位(如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均不能完成工作,而银行家手中又没有钱了,系统陷入僵持局面,银行家也不能收回投资。

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

 

6. 生产者与消费者模式

生产者与消费者模式的介绍

在实际的软件开发过程中,经常会碰到如下场景:某个模块负责产生数据,这些数据由另一个模块来负责处理(此处的模块是广义的,可以是类、函数、线程、进程等)。产生数据的模块,就形象地称为生产者;而处理数据的模块,就称为消费者。

在生产者与消费者之间在加个缓冲区,我们形象的称之为仓库,生产者负责往仓库了进商 品,而消费者负责从仓库里拿商品,这就构成了生产者消费者模型。结构图如下:

生产者消费者模型的优点:

1、解耦

假设生产者和消费者分别是两个类。如果让生产者直接调用消费者的某个方法,那么生产者对于消费者就会产生依赖(也就是耦合)。将来如果消费者的代码发生变化, 可能会影响到生产者。而如果两者都依赖于某个缓冲区,两者之间不直接依赖,耦合也就相应降低了。

举个例子,我们去邮局投递信件,如果不使用邮筒(也就是缓冲区),你必须得把信直接交给邮递员。有同学会说,直接给邮递员不是挺简单的嘛?其实不简单,你必须 得认识谁是邮递员,才能把信给他(光凭身上穿的制服,万一有人假冒,就惨了)。这就产生和你和邮递员之间的依赖(相当于生产者和消费者的强耦合)。万一哪天邮递员换人了,你还要重新认识一下(相当于消费者变化导致修改生产者代码)。

而邮筒相对来说比较固定,你依赖它的成本就比较低(相当于和缓冲区之间的弱耦合)。

2、支持并发

由于生产者与消费者是两个独立的并发体,他们之间是用缓冲区作为桥梁连接,生产者只需要往缓冲区里丢数据,就可以继续生产下一个数据,而消费者只需要从缓冲区了拿数据即可,这样就不会因为彼此的处理速度而发生阻塞。

接上面的例子,如果我们不使用邮筒,我们就得在邮局等邮递员,直到他回来,我们才能把信件交给他,这期间我们啥事儿都不能干(也就是生产者阻塞);或者邮递员得挨家挨户问,谁要寄信(相当于消费者轮询)。

3、解决忙闲不均

缓冲区还有另一个好处。如果制造数据的速度时快时慢,缓冲区的好处就体现出来了。

当数据制造快的时候,消费者来不及处理,未处理的数据可以暂时存在缓冲区中。 等生产者的制造速度慢下来,消费者再慢慢处理掉。为了充分复用,我们再拿寄信的例子来说事。假设邮递员一次只能带走1000封信。万一某次碰上情人节(也可能是圣诞节)送贺卡,需要寄出去的信超过1000封,这时 候邮筒这个缓冲区就派上用场了。邮递员把来不及带走的信暂存在邮筒中,等下次过来时再拿走。

常见的线性数据结构

队列:FIFO(先进先出)

由于队列是很常见的数据结构,大部分编程语言都内置了队列的支持,有些语言甚至提供了线程安全的队列。因此,开发人员可以捡现成,避免了重新发明轮子。

所以,假如数据流量不是很大,采用队列缓冲区的好处还是很明显的:逻辑清晰、代码简单、维护方便。

栈:FILO(先进后出)

Queue 模块

Python 的 Queue 模块中提供了同步的、线程安全的队列类,包括 FIFO(先入先出)队列 Queue、LIFO(后入先出)队列 LifoQueue,以及优先级队列 PriorityQueue。这些队列都实现了锁原语(可以理解为原子操作,即要么不做,要么就做完),能够在多线程中直接使用,实现线程间的同步。

  • 阻塞队列:当阻塞队列为空时,从队列中获取元素的操作将会被阻塞;当阻塞队列为满时,从队列里添加元素的操作将会被阻塞。
  • 线程安全:线程安全的类 ,指的是类内共享的全局变量的访问必须保证是不受多线程形式影响的。如果由于多线程的访问(比如修改、遍历、查看)而使这些变量结构被破坏或者针对这些变量操作的原子性被破坏,则这个类就不是线程安全的。

示例:使用 FIFO 队列实现生产者与消费者模式

 1 from threading import Thread
 2 from queue import Queue
 3 import time
 4 
 5 
 6 class Producer(Thread):
 7     def run(self):
 8         global queue
 9         count = 0
10         while True:
11             if queue.qsize() < 1000:
12                 for i in range(100):
13                     count = count + 1
14                     msg = "产出产品"+str(i)
15                     queue.put(msg)
16                     print(msg)
17             time.sleep(0.5)
18 
19 
20 class Consumer(Thread):
21     def run(self):
22         global queue
23         while True:
24             if queue.qsize() > 100:
25                 for i in range(3):
26                     msg = "消费了:"+queue.get()
27                     print(msg)
28             time.sleep(1)
29 
30 
31 if __name__ == "__main__":
32     queue = Queue()
33 
34     # 两个生产者线程
35     for i in range(2):
36         p = Producer()
37         p.start()
38     # 五个消费者线程
39     for i in range(5):
40         c = Consumer()
41         c.start()
42     

  

7. GIL(全局解释器锁)

什么是 GIL?

GIL 是计算机程序设计语言解释器用于同步线程的一种机制,它指在同一进程下的多线程执行时,只有拥有 GIL 锁的线程能够在 CPU 中进行运算执行。如果该线程在时间片用完时或者在执行时遇到 I/O 操作,就会释放 GIL 锁 ,转而将锁发放给其他竞争到 CPU 资源的线程拥有。

在一个进程中只有一个 GIL 锁,那么这多个线程中谁拿到 GIL 谁就可以使用 CPU(多个进程有多个 GIL 锁)。

GIL 并不是 Python 的特性,而是 Cpython 引入的一个概念。

  • Python 的代码由 Python 的解释器执行(CPython),那么我们的代码什么时候被 Python 解释器执行,由我们的 GIL 进行控制。
  • 当我们有一个线程开始访问解释器的时候,GIL 会将这把锁上锁,也就是说其他线程无法再访问解释器,也就意味着其他的线程无法再被执行。

问题: 什么时候会释放 GIL 锁?

  • 遇到像 I/O 操作这种会有时间空闲情况造成cpu闲置时,会释放GIL。
  • 会有一个专门的 ticks 进行计数,一旦 ticks 数值达到 100,这时就会释放 GIL 锁,线程之间开始竞争 GIL 锁(ticks 这个数值可以进行设置来延长或者缩减)。

问题:互斥锁和 GIL 锁的关系

  • GIL 锁:保证同一时刻只有一个线程能使用到 CPU。
  • 互斥锁:多线程时保证修改共享数据时是有序的修改,不会产生数据修改混乱。

GIL 解决了单核 CPU 多任务的问题,但随着硬件的发展,同样也暴露出了 GIL 的缺点,即在多核 CPU 的今天,不能实现多线程的并行执行。

问题:如何解决 GIL 问题

    1.更换 Cpython 为 Jpython 等其他解释器。

    2.使用多进程完成多线程的任务。

    3.在使用多线程可以使用 C 语言去实现。(下文介绍)

问题:既然有如此弊端,为什么 Python 当初这样设计加入 GIL 呢?

在那个单核年代,龟叔的 GIL 概念算是完美了解决单核多任务的想法。

首先要理解 Python 的对象管理机制,CPython 在创建变量时会分配内存,然后用一个计数器计算对该变量的引用的次数。这个概念叫做“引用计数”。如果引用的数目为 0,那就可以将这个变量从系统中释放掉。这样,创建“临时”变量(如在 for 循环的上下文环境中)不会耗光应用程序的内存。

比如:线程 A 与线程 B 同时引用对象 obj,那么 obj 的计数为 2,当 ojb 计数为 0 的时候就会将对象释放。但这个时候就会涉及到一个安全问题,即如果多线程的时候引用一个对象的时候,会出现数据安全问题。

当 A 或 B 运行后 count = 1-1 等于 0,那么 A 或者 B 就会释放 obj,但是另一个线程再调用 obj 的时候发现其已经不存在,因为前面的 obj 已经被释放掉了。所以引用了 GIL,保证了程序数据的安全。

在多线程编程中,你要确保改变内存中的变量时,多个线程不会试图同时修改或访问同一个内存地址。

随之而来的问题就是,如果变量在多个线程中共享,那么 CPython 需要对引用计数器加锁,即有一个“全局解释器锁”会谨慎地控制线程的执行。不管有多少个线程,解释器一次只能执行一个操作。

这个是很诡异的一件事情,因为在多线程并发的时候为了保证程序运行的数据安全加入的锁,但到后面想要摆脱的时候发现很难将其剥离,这意味这其再设计这门语言的时候几乎已经决定了后面发展版本对其的依赖,也就是短期之内很难将 GIL 这个弊端修改掉。

虽然 GIL 有其弊端,但是其也有很多优点不应该抹去,比如到 I/O 密集操作的时候(read、write、send、recv)期间,线程会释放 GIL,实现多线程的并行,因此在执行 I/O 密集型任务时会提升执行速度,但是对于 CPU 密集型任务则会拖慢速度。即 Python 的多线程在多核 CPU 上,只对于 I/O 密集型计算产生正面效果;而当有至少有一个 CPU 密集型线程存在,那么多线程效率会由于 GIL 而大幅下降。

所以 Python 引入了进程 multiprocessing 这个多进程机制,多个 Python 进程有各自独立的 GIL 锁,互不影响,来实现并行任务。

解决 Python 多线程 GIL 问题:使用 C 语言

1)编写 loop.c(c 代码文件)
void DeadLoop()
{
    while(1)
    {
        ;
    }
} 
2)read.me(编译说明)
  1. 把一个c语言文件编译成一个动态库的命令(linux平台下):gcc xxx.c -shared -o libxxxx.so
  2. 执行后生成 libdeadloop.so
3)编写 main.py
from ctypes import *
from threading import Thread
 
#加载动态库
lib = cdll.LoadLibrary("./libdeadloop.so")
 
#创建一个子线程,让其执行c语言编写的函数,此函数是一个死循环
t = Thread(target=lib.DeadLoop)
t.start()
 
#主线程,也调用c语言编写的那个死循环函数
#lib.DeadLoop()
 
#线程2
while True:
    pass

执行结果:

双核 CPU 均为 100%。

 

posted @ 2020-03-08 13:35  Juno3550  阅读(1489)  评论(0编辑  收藏  举报