Advertisement

多进程与多线程区别

阅读量:

众所周知,在一台现代计算机上我们可以灵活地管理多个应用程序的运行我们能够同时打开多个应用程序例如浏览网页收听音乐编辑文字等这些看似平常的操作背后蕴含着复杂的机制这些机制正是基于两个基础概念:多进程与多线程。
如果我们深入思考会发现为什么计算机能够支持如此多项的任务呢?这是因为现代计算机通过高效的资源调度算法实现了对大量程序的并行执行从而满足了用户的同时操作需求。

全局解释器锁(英语:Global Interpreter Lock, 缩写GIL)是一种编程语言解释器同步多线程的一种机制。它确保在同一时间只允许一个线程运行。即使在多核处理器上使用 GIL 解释器也只能同时运行一个线程。常见的带有 GIL 支持的解释器包括 Python 和 Ruby MRI。

假如有些地方不明白也没关系

比如图中,假如你开了两个线程(Py thread1Py tread2 ),

当Py thread1(Py thread1)启动执行时,在我们的解释器中申请获取一个锁。即为我们的 GIL lock

当程序接收一个请求时,它会向我们的OS提交请求以获取系统线程;

系统实现了线程的统一,在运行过程中就会在你的 CPU 上执行。(假设你现在拥有四核CPU)

4. 而我们的另一个线程二(py thread2 )也在同步运行。

在线索二试图向该Python解释器请求 GIL 时会陷入停滞(此处指该解释器),原因在于其 GIL 锁已被线索一获取(即表示该程序要想执行必须先解锁这把锁);

6. 线程二要想启动,就必须在我们的线程一执行完毕后(即我们释放了 GIL 后(图片中的第5步),线程二才能获得这把锁)。

7. 当线程二拿到这把锁之后就和线程一的运行过程一样。

① 启动 > ② 全局解释器锁(GIL) > ③ 请求操作系统提供的原生线程 > ④ 中央处理器运行(如果有其他进程运行,则会停滞于Python解释器之外)

这个锁本质上是Python之父为了实现一次性解决多线程执行时的安全隐患而设计的一个巧妙解决方案(其核心功能即是防止多个线程在同一时间运行)

2. 多线程的含义

说起多核程序就要从理解子程序开始讨论其行为机制的问题了

进程我们可以理解为是一个可以独立运行的程序单位。

比如:

  1. 运行一个浏览器程序, 就会启动一个新的浏览器进程;
  2. 运行一个文本编辑器程序, 就会启动相应的文本编辑器进程.

但一个进程中是可以同时处理很多事情的。

比如:在浏览器中,我们可以在多个选项卡中打开多个页面。

  • 某些页面正在播放音乐,
  • 某些页面正在播放视频,
  • 这些网页可能同时运行而不互相影响。

为什么能同时做到同时运行这么多的任务呢?

有必要引出线程这一概念。其实每一个具体的任务都应当被看作是一个独立运行的线程。

而进程呢?

由多个线程组成的集合体就是它;进程是由单个或多个线程所构成的一个整体;它是操作系统中负责执行运算调度的核心组件;在进程内部被划分成独立运行的基本单元。

比如:

所述的网络进程,在其中嵌入了音乐播放功能时会创建一个独立的线程,在嵌入视频内容时也会创建另一个独立的线程;当然,在这个系统中还有很多其他功能模块也在运行中创建各自的线程。这些不同功能模块所对应的并发或并行执行最终导致整个网络能够高效地处理多项任务

掌握了一个进程中的子进程协调机制之后

3. 并发和并行

在讨论多进程与多线程时,则有必要进一步阐述这两个概念

3.1 并发

通常用英语将其称为Concurrency. 它指的是在同一时间段内仅能执行单个指令。然而,在多线程环境中,则会通过快速切换的方式轮流处理各个线程的指令。例如:

一个处理设备首先运行A指令若干时间单位,在完成这些操作后接着运行B指令若干时间单位,在完成这两部分操作后又切换回A指令继续执行剩余的操作段时间

由于处理器以极其迅速的速度执行指令,并且切换上下文也非常快捷,在这种情况下,在人类看来似乎只有一个线程在运行。尽管从宏观上看看似有多余的线程同时运行着——实际上,在任何瞬间这个处理器都在交替地执行各个进程中的指令。

3.2 并行

在英语中被称为 parallel,在同一时间点,在多个处理器上同时运行,并行计算依赖于拥有多个处理单元。无论从宏观视角还是微观层面来看,并行运算都是在同一时间点展开的。

并行操作仅限于多处理器系统环境 ,对于单核处理器而言,并行操作根本无法实现。

然而,在单核处理器以及多核处理器系统中,并发现象都是能够存在的。从而能够在仅有一个核心的情况下实现并行处理。

举个例子

例如系统处理器需要同时处理多个线程。如果只有一个核心,则无法单独完成所有任务;它必须依靠并行的方式来分配不同的任务到各个核心上进行处理。如果有多个核心,则每个核心都可以独立运行自己的任务;这样就能实现多任务的同时处理。同样地,在同一核心上进行任务的切换也会导致不同任务被分配到不同的计算资源上进行同步操作。具体采用何种调度策略由操作系统来决定。

例如系统处理器需要同时处理多个线程。如果只有一个核心,则无法单独完成所有任务;它必须依靠并行的方式来分配不同的任务到各个核心上进行处理。如果有多个核心,则每个核心都可以独立运行自己的任务;这样就能实现多任务的同时处理。同样地,在同一核心上进行任务的切换也会导致不同任务被分配到不同的计算资源上进行同步操作。具体采用何种调度策略由操作系统来决定。

4. 多线程适用场景

在程序运行过程中, 存在一些操作需要较长的时间完成或必须依次等待, 例如, 在等待数据库返回查询结果的同时, 还需处理网页返回响应的过程。若采用单线程方式, 处理机必须依次完成这些任务后才能继续处理后续的操作; 然而, 在这些任务排队的过程中, 处理机仍然有机会执行其他可中断的任务以提升整体效率。当采用多线程策略时, 处理机可以在某个子任务排队期间切换至处理其他子任务, 这样能更有效地利用资源并提高系统吞吐量

像上述场景,线程在执行过程中很多情况下是需要等待的。

比如

网络爬虫就是一个非常经典的例子,在向服务器发起请求后需要等待一段时间以获得响应返回;这类任务被称为 IO 密集型任务。通过启用多线程技术,在某个线程等待期间处理器可以处理其他任务,并显著提升了整体的爬取效率。

但并不是所有的类型都属于 IO 瓶颈类的任务;还有一种类型被称为计算密集型的任务;这也可统称为 CPU 瓶颈类的任务。举个例子来说吧:当我们在运行多个并行处理时;一个处理器会持续地从一种类型切换到另一种类型;这种情况下并不会提高整体效率;反而可能会导致开销增大。

所以,在非计算密集型任务的情况下,则可以通过多线程技术来显著提升程序的整体运行效率。特别地,在处理需要大量I/O操作的任务时,则能够更加高效地完成数据采集工作。对于像网络爬虫这类依赖于大量I/O操作的任务而言,则能通过采用多线程技术来显著提高数据采集过程的速度。

5. Python 实现多线程

通过 Python 提供一种内置的多线程支持功能,在软件开发中称为 threading 模块。让我们来探讨如何利用 threading 实现多线程处理的方法。

在着手具体实现之前, 我们先对多线程与单线程的崩溃速度进行测试, 以期更加直观地了解两者之间的性能差异. 这里采用分别列出每种线程的具体代码, 并进行比较分析的方法.

单线程裸奔:(这也是一个主线程(main thread))

注意:由于各台电脑性能存在差异,导致运行结果会有所差异(具体情况具体分析)

接下来我们写一个多线程

首先,我们建立一个字典(thread_name_time),用于记录每个线程名称及其对应的时间。

从运行效率的角度来看, 我们可以看出速度上的区别并不显著. 相比而言, 多路处理机制在处理计算密集型任务时所带来的时间损耗明显大于其潜在的优势. 这一结果的根本原因在于 GIL 在这一场景下处于激活状态, 因此不具备适用性.

在这些操作都必须依赖 CPU 执行时,请确保代码能够高效运行。由于受限于 GIL 机制,在同一时间只能有一个线程运行以避免性能瓶颈问题。因此,在大多数情况下,这类应用属于 I/O 密集型任务。

BIOS:B:Base、I:Input、O:Output、S:System

也就是你电脑一开机的时候就会启动。

1. 计算密集型

当处理某个任务时

那从上图可知,那这两个线程就需要频繁的在上下文切换。

Ps:我们这个绿色表示我们这个线程正在执行,红色代表阻塞。

因此,在线程中进行上下文切换(以ms为单位的时间)会不断地归还和获取GIL等操作,并进行context switching。这显著地造成了大量的资源浪费。

2. IO 密集型

我们假设有一个服务器程序(Socket),即为我们的网络爬虫核心模块的一个新进程,并开始抓取目标网页内容。该网页有两个独立线程并行执行,在此过程中,线程二已成功发起请求并启动运行状态。上图显示为(Thread 2)绿色一条路过去的情况。

而我们的线程一(Thread 1)实现了 UDP 格局,并在等待返回的 HTML 和 CSS 数据之前进入准备状态。这意味在整个 recvfrom 过程中始终处于准备状态。这种持续阻塞的情况使得我们的线程二能够无休止地运行而不需切换上下文。这种高密集度的 I/O 操作能够带来显著的优势。

IO密集型的情况下,则会将我们的等待时间纳入计算之中,并大大缩短了处理时间。
这里我们需要特别注意的是:我们的多线程运行在IO密集型环境下。
此外,在资源等待的过程中:
比如有时候我们会通过浏览器发起一个Get请求。
当浏览器图标上显示在转动的时候(也就是图中所示的Datagram到Ready to receive阶段),属于数据建立到数据接收这个阶段。
而这个过程并不需要亲自执行下去,
我们可以让另一个线程去处理它,
从而避免占用当前资源。

换句话说相当于,在多线程场景下我们设计了一个机制:当第一个进程在循环访问某个网页时第二个进程得以同步执行以完成数据抓取任务这样不仅提高了资源利用率而且减少了等待时间

注意

5.1 使用Thread对象可以直接生成子线程
5.1.1 non-protected threads
在进行复杂的操作之前,请先从一个简单的示例开始。

如果有参数的话,我们就对多线程参数进行传参数。代码示例:

解析:

我认认真看一下我们的运行结果,

startstopmy first threadTrue2968

我们会发现并不是按我们正常的逻辑执行这一系列的代码。

而不是,在完成 start 之后会立即停止;只有在完成这些步骤之后才会执行我们函数的其他三项。

这条线程会一直进行下去。也就是说,在执行完主线程代码后才会开始处理其中的子线程代码。

我们的代码并不是等到 thread.start() 执行完毕后再调用 print(‘stop’) 。而是,在线程启动(thread.start())后立即就会继续往下运行,并在此时就开始处理内部代码。(不会被阻塞在 thread.start() 这里)同时,在主线程终止后也不会停止。

由于程序在执行到 print(‘stop’) 之后就终止了,并且我们自己启动了子线程。

当主线程执行此 stop 后就已终止。

这些不会因主线程结束而被销毁的子线程被称为:非守护线程。

  1. 主线程会跳过创建的线程继续执行;
  2. 直到创建线程运行完毕;
  3. 程序结束;

既然存在非守护线程,则必定还存在守护线程。无需担心,请再举一个非守护线程的例子。

首先我们可以借助Thread类搭建一个新线程在创建过程中请指定target参数为其执行的目标方法名称当目标方法涉及额外参数时请通过Thread对象提供的args属性来配置这些参数

在本段中我们首先声明了一个名为target的方法该方法接受一个名为second的参数。该方法的工作原理体现在其内部实现中可以看出它本质上就是一个执行time.sleep操作的时间函数其中second参数指定的是等待时间以秒计取值范围在0到1之间当主线程运行时返回值为MainThread而若为子线程则返回值为Thread-*类型的名字。

然后我们利用 Thead 类创建了两个新线程,并将 target 参数设置为之前定义的方法名,并将 args 作为列表形式传递给这两个线程对象。在两次循环过程中,默认情况下 i 的值分别为 1 和 5,在此过程中两个线程将分别休眠1秒及5秒的时间长度,在声明完成后调用 start 方法即可启动线程运行

通过观察分析可以看出, 出现了三个进程, 其中包含一个主进程中MainThread以及两个副进程中Thread-1与Thread-2. 进一步观察发现, 主进程中最先完成任务, 而随后的两个副进程中依次完成任务, 分别相隔约1秒与4秒的时间段. 由此可见,在这种情况下主进程中并未等待所有副进程中完成才自行退出系统.

为了确保主线程仅在所有子线程都完成任务后才会退出,请让每个子线程对象在调用join方法前先阻塞一段时间。

这样,主线程必须等待子线程都运行结束,主线程才继续运行并结束。

5.2 继承 Thread 类创建子线程

此外

复制代码
 import threading, timeclass MyThread(threading.Thread):

    
     def __init__(self, second):
    
     threading.Thread.__init__(self)
    
     self.second = second    def run(self):
    
     print(f'Threading {threading.current_thread().name} is runing')
    
     print(f'Threading {threading.current_thread().name} sleep {self.second}s')
    
     time.sleep(self.second)
    
     print(f'Threading {threading.current_thread().name} is ended')
    
  
    
 print(f'Threading {threading.current_thread().name} is runing')for i in [1, 5]:
    
     t = MyThread(i)
    
     t.start()
    
     t.join()
    
 print(f'Threading {threading.current_thread().name} is ended')# 输出Threading MainThread is runing
    
 Threading Thread-1 is runing
    
 Threading Thread-1 sleep 1s
    
 Threading Thread-1 is ended
    
 Threading Thread-2 is runing
    
 Threading Thread-2 sleep 5s
    
 Threading Thread-2 is ended
    
 Threading MainThread is ended

可以看到,两种实现方式,其运行效果是相同的。

5.3 守护线程

在多线程系统中存在一种称为守护线程的特殊角色。当主线程序退出时,在其之后启动的任务若无优先级,则会被指定为由该守护进程处理。这种机制能够确保即使主线程序终止了也不会立即释放资源而造成潜在的问题。通过Python的setDaemon方法可以实现这一点。

如果要修改成守护线程,那你就得在 thread.start()前面加一个:

需要在我们启动之前设置。

示例一如下:

添加之前:

复制代码
 import threading, timedef start(num):

    
     time.sleep(num)
    
     print(threading.current_thread().name) # 当前线程的名字
    
     print(threading.current_thread().isAlive())
    
     print(threading.current_thread().ident)
    
  
    
 print('start') # 主线程开始thread = threading.Thread(target=start,name='my first thread', args=(1,))# 可以使用 for 循环来添加多个thread.start()
    
 print('stop') # 主线程结束# 运行结果start
    
 stop
    
 my first threadTrue15816

添加之后:

复制代码
 import threading, timedef start(num):

    
     time.sleep(num)
    
     print(threading.current_thread().name) # 当前线程的名字
    
     print(threading.current_thread().isAlive())
    
     print(threading.current_thread().ident)
    
  
    
 print('start') # 主线程开始thread = threading.Thread(target=start,name='my first thread', args=(1,))# 可以使用 for 循环来添加多个thread.setDaemon(True) # 在 start 开始之前设置thread.start()
    
 print('stop') # 主线程结束# 运行结果start
    
 stop

我们能够看见,在Python中程序直接运行:启动/停止(start、stop),执行至print(‘stop’),任务即完成。该函数调用立即终止。即便主线程提前终止前(也不会理会其中的time.sleep()函数),我们的守护线程就会随着主线程一起摧毁。

我们日常启动的是非守护线程,守护线程用的较少。

守护线程会伴随主线程一起结束,setDaemon设置为 True 即可。

示例二如下:

添加之前:

复制代码
 import threading, timedef target(second):

    
     print(f'Threading {threading.current_thread().name} is runing')
    
     print(f'Threading {threading.current_thread().name} sleep {second}s')
    
     time.sleep(second)
    
     print(f'Threading {threading.current_thread().name} is ended')
    
  
    
 print(f'Threading {threading.current_thread().name} is runing')
    
 t1 = threading.Thread(target=target, args=[2])
    
 t1.start()
    
 t2 = threading.Thread(target=target, args=[5])
    
 t2.start()
    
 print(f'Threading {threading.current_thread().name} is ended')# 运行结果Threading MainThread is runing
    
 Threading Thread-1 is runing
    
 Threading Thread-1 sleep 2s
    
 Threading Thread-2 is runing
    
 Threading Thread-2 sleep 5s
    
 Threading MainThread is ended
    
 Threading Thread-1 is ended
    
 Threading Thread-2 is ended

添加之后:

复制代码
 import threading, timedef target(second):

    
     print(f'Threading {threading.current_thread().name} is runing')
    
     print(f'Threading {threading.current_thread().name} sleep {second}s')
    
     time.sleep(second)
    
     print(f'Threading {threading.current_thread().name} is ended')
    
  
    
 print(f'Threading {threading.current_thread().name} is runing')
    
 t1 = threading.Thread(target=target, args=[2])
    
 t1.start()
    
 t2 = threading.Thread(target=target, args=[5])
    
 t2.setDaemon(True)
    
 t2.start()
    
 print(f'Threading {threading.current_thread().name} is ended')# 运行结果Threading MainThread is runing
    
 Threading Thread-1 is runing
    
 Threading Thread-1 sleep 2s
    
 Threading Thread-2 is runing
    
 Threading Thread-2 sleep 5s
    
 Threading MainThread is ended
    
 Threading Thread-1 is ended

我们使用setDaemon方法将t2指定为守护线程;当主线程完成运行时,t2会自然退出随主线程一起终止。

运行结果:

复制代码
 Threading MainThread is runing

    
 Threading Thread-1 is runing
    
 Threading Thread-1 sleep 2s
    
 Threading Thread-2 is runing
    
 Threading Thread-2 sleep 5s
    
 Threading MainThread is ended
    
 Threading Thread-1 is ended

观察到我们未检测到 Thread-2 打印机退出的消息,并且由于主线程退出导致 Thread-2 也退出了

可能会发现细心的人会注意到,并没有被调用join方法的情况发生;如果让t1与t2各自都调用join方法的话,则主线程将一直等待所有子线程完成后再退出,并且这一结果与它们是否为守护线程无关。

5.4 互斥锁

接下来是比较难的知识点,还是从简单的知识点开始。

例如我们现在有两个线程其中一个是执行加法操作一百万次另一个是执行减法操作一百万次按照原本的设计方案这两个操作的结果应该相互抵消最终得出的结果应该是零然而在实际运行中我们发现每次得到的结果都不相同

复制代码
 import threadingimport time

    
  
    
 number = 0def addNumber(i):
    
     time.sleep(i)    global number    for i in range(1000000):
    
     number += 1
    
     print("加",number)def downNumber(i):
    
     time.sleep(i)    global number    for i in range(1000000):
    
     number -= 1
    
     print("减",number)
    
  
    
 print("start") # 输出一个开始thread = threading.Thread(target = addNumber, args=(2,)) #开启一个线程(声明)thread2 = threading.Thread(target = downNumber, args=(2,)) # 开启第二个线程(声明)thread.start() # 开始thread2.start() # 开始thread.join()
    
 thread2.join()# join 阻塞在这里,直到我们得阻塞线程执行完毕才会向下执行print("外", number)
    
 print("stop")

即使在单线程环境下也会产生两个数值:正负一百万(或其他数值),这取决于谁先执行操作。这是因为所有的操作都是基于同一个全局变量 number 进行的。假设加法函数先被调用,则 number 的值会被增加到相应的数值;随后减法操作会得到相应结果。为了避免这种不确定性,在实际应用中通常建议采用多态机制来实现此类功能而不是简单的全局变量共享。注释说明了重复原因及解决办法

复制代码
 import threadingimport time

    
  
    
 number = 0def addNumber(i = None):
    
     # time.sleep(i)
    
     global number    for i in range(1000000):
    
     number += 1
    
     print("加",number)def downNumber(i = None):
    
     # time.sleep(i)
    
     global number    for i in range(1000000):
    
     number -= 1
    
     print("减",number)
    
  
    
 addNumber()
    
 downNumber()
    
 print(number)# 运行结果加 1000000减 00# 反过来运行downNumber()
    
 addNumber()
    
 print(number)# 运行结果减 -1000000加 00# 再来一个差不多的例子:import threadingimport time
    
  
    
 number = 0def addNumber():
    
     global number    for i in range(1000000):
    
     number += 1
    
     print("加",number)    return numberdef downNumber():
    
     global number    for i in range(1000000):
    
     number -= 1
    
     print("减",number)    return number
    
  
    
 sum_num = downNumber() + addNumber()
    
 print("Result", sum_num)# 输出减 -1000000加 0Result -1000000# 修改以下代码,其他不变:sum_num = addNumber() + downNumber()# 输出加 1000000减 0Result 1000000

从上述多线程代码可以看出:两个线程对同一个数字进行操作会导致最终结果显得混乱。那为什么会出现这样的状况呢?

下一步要做的是一个赋予数值的操作。number += 1 实际上等同于将当前数值加一,在Python中我们计算右边部分后将结果赋予左边变量

我先来看一下正确的运行流程:

复制代码
 # 我们的 number = 0

    
 # 第一步是先运行我们的代码:a = number + 1 
    
 # 等价于 0+1=1 # 也就是先运行右边的,然后赋值给 anumber = a 
    
 # 然后,再把 a 的结果赋值个 number
    
 # 上面运行完加法之后,我们加下来运行减肥的操作。b = number - 1 
    
 # 等价于 1-1 = 0
    
 # 然后,赋值个 number# 最后 number 等于 0number = 0

上面的过成是正确的流程,可在多线程里面呢?

复制代码
 number = 0

    
 # 开始初始值 0a = number+1 
    
 # 等价于 0+1=1
    
 # 这个地方要注意!!!
    
 # 在运行完上面一步的时候,还没来得急把结果赋值给 number
    
 # 就开始运行减法操作:b = number-1 
    
 # 等价于 0-1=-1
    
 # 然后,这两个运行结束之后就被赋值:number=b 
    
 # b = -1number=a 
    
 # a = 1# 最终得结果为:number = 1

上面就是我们刚才错误的根本原因:也就是说我们计算与赋值是两个独立的部分但是该multithreaded程序无法保证它们的顺序执行这也就是导致线程不安全的根本原因。

由于执行速度过快,在处理过程中这两个线程之间相互影响而导致了一个错误的结果。这就是为什么会出现线程不安全问题的根本原因所在。

这就是必须的 Lock 锁 ,为了确保其安全运行而安装锁定机制,并以确保其预期目标的效果出现。在这一情况下,请详细解释安装锁定机制前的情况如何?让我们接着探讨一些更为复杂的情况。

在一个进程中的多个线程是共享资源的

比如

在一个进程中,在一个特定的执行环境中有一个全局变量count被设计用于统计进程中的事件数量。为了验证多线程环境下的行为特性,在同一个进程中我们创建多个子线程,并让每个子线程在运行时递增count值。通过这种方式我们可以观察到结果如何变化并验证系统的稳定性与一致性表现。代码实现如下:

复制代码
 import threading, time

    
  
    
 count = 0class MyThread(threading.Thread):
    
     def __init__(self):
    
     threading.Thread.__init__(self)    def run(self):
    
     global count
    
     temp = count + 1
    
     time.sleep(0.001)
    
     count = temp
    
 threads = []for _ in range(1000):
    
     thread = MyThread()
    
     thread.start()
    
     threads.append(thread)for thread in threads:
    
     thread.join()# print(len(threads))print(f'Final count: {count}')

在这里, 我们初始化了10^3个线程; 每个线程都是通过读取当前全局变量count的值来进行操作; 在短暂休眠一段时间后将其赋值为新的数值.

按照常规情况而言,在没有特殊说明的情况下,默认情况下 count 值预计会是 1000。然而事实并非如此,在实际操作中观察到的结果可能与之不同。

运行结果如下:

复制代码
    Final count: 69

最后的结果居然只有 69,而且多次运行或者换个环境运行结果是不同的。

这是为什么呢?

由于 count 这个变量被多个线程共享,在每一个线程执行 temp = count 这行代码时都可以获取到当前的 count 值。然而,在这些线程中存在一些可能同时进行操作的情形。因此,这可能导致多个线程都使用同一个 count 值进行计算。最终会导致某些线程在增加 count 时的操作未被有效执行

所以,在多个线程同时访问或更改同一数据时(即对该数据进行读取或修改),这会导致不可预见的结果。为了避免这种情况的发生(即出现不可预测的问题),我们需要为这些线程实现同步机制(以便确保它们能够协调工作)。为此方法必须具备互斥性(即在资源被占用时阻止其他操作),在Python中我们可以通过引入 threading.Lock 来实现这个功能。

加锁保护是什么意思呢?

也就是说,在进行数据操作之前,“某个 thread 需要先获取 lock”。一旦某一线程完成对其 "lock" 的获取,则其他所有 thread 必须等待该 "lock" 的释放以便继续访问 "data"。“完成操作后及时释放 lock 是保证系统稳定性的关键步骤”。通过互斥机制保证只允许一个 thread 同时访问 "data" 并防止 multiple thread 同时 read 和 modify shared data。这样一来就能确保整个系统的正确运行。

我们可以将代码修改为如下内容:

示例一的修改:

复制代码
 import threadingimport time

    
  
    
 lock = threading.Lock() # 创建一个最简单的 读写锁number = 0def addNumber():
    
     global number    for i in range(1000000):
    
     lock.acquire() # 先获取
    
     number += 1
    
     # 中间的这个过程让他强制有这个计算和赋值的过程,也就是让他执行完这两个操作,后再切换。
    
     # 这样就不会完成计算后,还没来的及赋值就跑到下一个去了。
    
     # 这样也就防止了线程不安全的情况
    
     lock.release() # 再释放def downNumber():
    
     global number    for i in range(1000000):
    
     lock.acquire()
    
     number -= 1
    
     lock.release()
    
  
    
 print("start") # 输出一个开始thread = threading.Thread(target = addNumber) #开启一个线程(声明)thread2 = threading.Thread(target = downNumber) # 开启第二个线程(声明)thread.start() # 开始thread2.start() # 开始thread.join()
    
 thread2.join()# join 阻塞在这里,直到我们得阻塞线程执行完毕才会向下执行print("外", number)
    
 print("stop")# 输出start
    
 外 0stop

在代码:lock.acquire() 和 lock.release() 这个环节中所涉及的步骤会确保计算完成后立即进行赋值。也就是说,在完成这两个操作后才进行切换以避免在切换前还没有完成赋值就跳转到下一个线程这一步骤从而确保多线程环境下的数据一致性

也就是第一个线程持续地拿到这把锁的 lock.acquire() 操作。一旦这个操作完成之后,则会使得其他线程处于持续地阻塞状态,在等待 lock.acquire() 过程中无法继续操作。只有当我们另一个线程能够持续地释放 lock.release() 后,在进入 next 节点之前才能继续自己的操作流程。接着就可以持续地拿到锁执行下去了。

死锁: 即使是持有锁的线程完成任务却不将其归还给其他线程导致后者在此前一个线程完成任务的过程中被阻塞的现象称为死锁。这种状态类似于彼此期待的结果:一方期待另一方先履行承诺而另一方则持续处于等待状态形成一种互为制约的关系。(在程序设计中这就好比两个变量试图同时访问同一个存储单元从而引发不可调和的竞争)

示例二的加锁

复制代码
 import threading, time

    
  
    
 count = 0class MyThread(threading.Thread):
    
     def __init__(self):
    
     threading.Thread.__init__(self)    def run(self):
    
     global count
    
     lock.acquire() # 获取锁
    
     temp = count + 1
    
     time.sleep(0.001)
    
     count = temp
    
     lock.release() # 释放锁lock = threading.Lock()
    
 threads = []for _ in range(1000):
    
     thread = MyThread()
    
     thread.start()
    
     threads.append(thread)for thread in threads:
    
     thread.join()
    
 print(f'Final count: {count}')

我们在此处声明了一个 lock 对象(其实就是 threading.Lock 实例的一个副本),然后,在 run 方法内部,在获取 count 之前先加锁(修改完 count 后立即释放锁)。从而确保多个线程不会同时访问并修改 count 的值。

运行结果如下:

复制代码
    Final count: 1000

熬了两个通宵写的!终于把多线程和多进程彻底讲明白了!

2020年4月20日17:45·编程小蝉

在一台电脑上运行多个应用程序,并非什么稀奇古怪的事情。然而深入思考一下这个问题背后的原因,则会发现它主要涉及两个核心概念:多进程与多线程。

在开发爬虫程序的过程中,在编写脚本的时候为了提升执行速度而考虑同时运行多个爬虫任务这一问题同样涉及多进程与多线程的知识

本篇文章中, 我们将深入探讨多线程的核心概念, 并详细阐述其在Python中的实现方法

1. 全局解释器锁

全局解释器锁 (英语:Global Interpreter Lock,缩写 GIL

计算机程序设计语言解释器 用于 同步线程 的一种机制,它使得任何时刻仅有 一个线程 在执行,即便在 多核心处理器 上,使用 GIL 的解释器也只允许同一时间执行一个线程。常见的使用 GIL 的解释器有 CPythonRuby MRI

假如你对上述内容不明白也没关系。简单来说就是说:你的电脑配置为 一核或者多核 处理能力的情况下(即单个CPU核心或多个核心),但是由于 Python 的 GIL(Global Interpreter Lock)机制限制了这一点——你的代码试图启动多个线程执行时就会被这个机制所限制而无法实现真正意义上的多线程并行处理。

接下来,我们来用个图片来解释一下:

熬了两个通宵写的!终于把多线程和多进程彻底讲明白了!

比如图中,假如你开了两个线程(Py thread1Py tread2 ),

当Py thread1启动时,在我们的解释器中它会获取一个锁。
当一个请求被提交给解释器时,在OS层我们会分配相应的系统线程。
在CPU上运行(假设当前配置为四核CPU)。
同时启动的另一个进程是py thread2。
在尝试向Python解释器申请GIL时py thread2会出现阻塞(因为该锁已被Py thread1占用),即必须先完成当前任务才能继续。
为了取得该资源必须等待Py thread1完成当前操作并释放GIL(如图所示的步骤五)。
一旦取得GIL后其运行流程与Py thread1完全一致。

复制代码
    ① Create > ② GIL > ③ 申请原生线程(OS) > ④ CPU 执行(如果有其他线程,都会卡在 Python 解释器的外边)

这个锁其实是源于 Python 之父一次性解决多线程运行安全问题的核心方案(其核心功能是确保多线程同时执行的被禁止)

2. 多线程的含义

关于多线程的讨论往往离不开对"线程"这一概念的理解。然而,在深入探究"线程"的本质之前,请您首先明确"进程"的基本定义。

进程我们可以理解为是一个可以独立运行的程序单位。

比如:

  1. 启动一个浏览器窗口, 这就导致系统执行了启动一个浏览器进程的操作;
  2. 启动一个文本编辑器窗口, 这就导致系统执行了启动相应的文本编辑器进程的操作。

但一个进程中是可以同时处理很多事情的。

比如:在浏览器中,我们可以在多个选项卡中打开多个页面。

  • 某些页面正在展示音乐内容,
  • 某些页面正在展示视频内容,
  • 不仅某些网页正在展示动画内容,并且这些动画可以在同一时间段运行而不互相影响。

为什么能同时做到同时运行这么多的任务呢?

这部分内容涉及线程的概念;这些具体任务实际上对应每个线程的运行过程。

而进程呢?

由多个线程组成的集合体称为线程;进程则可由单一或多个线程构成;运算是操作系统核心任务之一,在此框架下,运算是在线程层次上完成的任务;而在线程层次上具有极小运行粒度的操作称为操作单元

比如:

在我们讨论的这个应用实例中,音频内容作为一个子任务被启动,在另一个子任务中则为视频内容提供了展示空间;除此之外,在这个浏览器进程中还有许多其他正在执行的任务.

熟悉了线程的基本概念后

3. 并发和并行

关于多进程与多线程的知识点较为复杂,在此我们需要进一步阐述这两个概念:并发与并行。我们都知道,在计算机系统中处理一个程序时,默认情况下其底层由处理器通过依次执行一系列指令来完成任务。

3.1 并发

在英语中被称为 concurrency. 它指的是在同一时间只能有一条指令被执行, 但许多线程的相关指令被迅速轮流执行. 比如:

该处理器依次先执行线程 A 的指令一段特定的时间,并随后执行线程 B 的指令一段特定的时间;接着切换回线程 A 并再次执行其指令段。

考虑到处理器每秒能够处理和转换指令的数量极其庞大,人类几乎察觉不到计算机在这一瞬间调用多个线程进行操作.这也导致我们从宏观视角观察时会误以为多个独立的线程同时运行.然而,在微观层面来看,在这段时间内 processor 超级频繁地进行上下文切换与指令执行, while processor 在同一个时间段内快速轮换并处理各个进程.

3.2 并行

其英文名称为 parallel。它指的是,在同一时间点上运行多条指令,在多台处理器上同时执行,并行操作必定依赖这些处理器。无论从宏观层面还是微观层面来看,在同一时间段内会有多条线程协同工作完成任务。

并行仅限于多处理器系统的环境中 ,如果我们的计算机处理器只有一个核心,则无法实现并行。

然而,在单处理器和多处理器系统中都可能存在并发现象,并发的效果可以通过合理调度资源或优化算法设计等手段,在单一处理核心下也能达到并行处理的效果。

举个例子

例如,在多任务处理方面需要对系统处理器进行相应的优化设计。若系统处理器仅配备单个核心,则必须采用并发技术来完成各项任务的处理工作;而当配置为多核心架构时,则可以通过多线程技术实现对多个任务的并行支持;具体而言,在单个核心负责处理一个任务的同时另一核心则可切换至处理另一个任务;值得注意的是,在同一核心上可能还存在其他并行的任务运行情况;至于具体的执行策略则完全取决于操作系统的调度机制设定。

熬了两个通宵写的!终于把多线程和多进程彻底讲明白了!

4. 多线程适用场景

在一个程序进程中有一些操作往往具有较长的时间消耗或需 wait 完成 在线程设计中常见于等待数据库查询结果返回以及网页响应生成这两个场景 如果使用单线程架构在 wait 的这段时间内 processor 必须被动地 idle 无法执行其他任务 单线程架构下 processor 在 wait 的这段时间内必须完成当前任务才能开始 next 任务 而这种等待状态会占用大量处理器资源 导致整体性能下降 如果采用多线程设计在某个 thread 的 wait 过程中 processor 可以主动切换至执行其他 thread 的任务 多线程设计则不同 在某个 thread 的 wait 过程中 processor 可以主动切换至执行其他 thread 的任务 这种机制能够有效提升处理器利用率 并提高系统的整体吞吐量

像上述场景,线程在执行过程中很多情况下是需要等待的。

比如

网络爬虫是一个典型的例子,在发送请求后需要有一段时间进行服务器的响应等待;这种类型的任务被归类为IO密集型任务。对于这类任务,在开启多线程后,在某个线程等待期间处理器能够并行处理其他任务,并且能够显著提升网络数据的采集速率。

并非所有类型的任务都属于 IO 瓶颈型的任务;另一种类型的任务被称为计算密集型任务(CPU 瓶颈型)。举个例子说明:当开启多线程时;一个处理器从一个计算密集型任务切换到并立即投入另一个计算密集型任务;此时处理器并不会停止工作以减少总处理时间;因为所需处理的所有计算量始终保持不变。相反地;如果线程数量过多;不仅会导致频繁的切换开销;而且会占用更多资源导致整体效率下降。

因此,在非全计算密集型任务的情况下,“我们可以使用多线层来提高程序整体的执行效率。”特别地,在处理像网络爬虫这样的 IO 密集型任务时,“我们可以通过多层的方式来显著提升程序的整体运行效率。”

5. Python 实现多线程

在 Python 中,默认情况下提供了用于实现多线程的 threading 模块。让我们深入了解通过 threading 模块实现多线程的技术。

在着手具体实现之前进行测试,并且为了便于直观比较而将每种线程代码分别列出,并进行详细对比分析。

单线程裸奔:(这也是一个主线程(main thread))

复制代码
 import timedef start():

    
     for i in range(1000000):
    
     i += i    return# 不使用任何线程(裸着来)def main():
    
     start_time = time.time()    for i in range(10):
    
     start()
    
     print(time.time()-start_time)if __name__ == '__main__':
    
     main()

输出:

复制代码
553307056427002

注意:由于各台电脑的性能各有不同,运行的结果也会有所差异(具体情况而定)


接下来我们写一个多线程

为了便于后续操作管理需求, 我们将建立一个名为 (thread_name_time) 的映射结构, 用于记录每个线程的名称与其对应的时间信息。

复制代码
 import threading,timedef start():

    
     for i in range(1000000):
    
     i += i    return# # 不使用任何线程(裸着来)# def main():#     start_time = time.time()#     for i in range(10):#         start()#     print(time.time()-start_time)# if __name__ == '__main__':#     main()def main():
    
     start_time = time.time()
    
     thread_name_time = {}# 我们先创建个字典 (thread_name_time) 用来来存储我们每个线程的名称与对应的时间
    
  
    
     for i in range(10):        # 也就是说,每个线程顺序执行
    
     thread = threading.Thread(target=start)# target=写你要多线程运行的函数,不需要加括号
    
     thread.start()# 上一行开启了线程,这一行是开始运行(也就是开启个 run)
    
     thread_name_time[i] = thread # 添加数据到我们的字典当中,这里为什么要用i做key?这是因为这样方便我们join
    
  
    
     for i in range(10):
    
     thread_name_time[i].join()    #     join() 等待线程执行完毕(也就是说卡在这里,这个线程执行完才会执行下一步)
    
     print(time.time()-start_time)if __name__ == '__main__':
    
     main()

输出

复制代码
2037984102630615
复制代码
 # 6.553307056427002 裸奔

    
 # 6.2037984102630615 单线程顺序执行
    
 # 6.429047107696533 线程并发

我们可以通过对比分析表明,在性能层面并没有明显的差距。
从性能角度来看, 多线程并发与单线程顺序执行之间存在显著的性能差异。
从整体效益来看, 付出多而得到少的现象是得不偿失的。
造成这种现象的主要原因是 GIL 的限制。
在这里, 我们面对的是典型的计算密集型任务, 其特点决定了该方案并不适用。

当我们进行加法、减法、乘法或者图像处理操作时,在CPU上都需要特定的操作指令支持才可以完成任务。由于Python语言中存在全局 interpreter lock(GIL),在同一时间点只会有单一的线程负责运行这些操作。这正是导致我们在启动多个线程时无法并行处理的原因所在。

而我们的网络爬虫大多时候是属于 IO 密集与计算机密集

熬了两个通宵写的!终于把多线程和多进程彻底讲明白了!

BIOS:B:Base、I:Input、O:Output、S:System

也就是你电脑一开机的时候就会启动。

1. 计算密集型

在上面的时候,在开启两个线程时发现一个问题:当这两个线程需要同时运行时,在同一时期 CPU 上只能调度一个线程进行执行。
通过上图可以看出:当这两个线程需要频繁发生上下文切换的情况。
注释部分说明:我们用绿色来表示当前这个线程处于执行状态,红色则表示该线程处于阻塞状态。
由此可见:程序中的上下文切换操作实际上也在消耗一定的资源(以时间-ms计),包括不断归还并获取 GIL 等资源来进行切换操作。

2. IO 密集型

我们启动了一个服务器程序(Socket),也就是我们网络爬虫的底层程序开始抓取目标网页。这个目标网页有两个线程并行运行,在此之前我们已经启动了第二个线程(Thread 2),它通过绿色线路开始运行。

在处理第一个线程(Thread 1)时,我们采用了数据包传输层协议(Datagram),随后等待数据连接建立的过程(也就是等待HTML、CSS等数据返回)。在这个过程中,在准备阶段即"ready to receive (recvfrom)"之间都需要进行阻塞操作。

而由于第二个线程可以在整个准备阶段期间一直运行而不需切换上下文状态,因此这种高IO负载的特性具有显著的优势。

IO 密集型,这样就把我们等待的时间计算进去了,节省了大部分时间。

当前场景中需要特别注意的是,在这种情况下我们的多线程基于 IO密集型环境运行,并且必须明确地区分。

此外,在资源加载过程中(比如有时候我们使用浏览器发起了一个 Get 请求),当浏览器图标上显示转圈圈时(即从 **Datagram 到 Ready to receive 这一阶段)就是我们请求资源等待的时间。数据建立到数据接收(也就是图上面的 **转圈圈 的时间)。此时我们可以将另一个线程分配到这里面进行处理。

换句话说:第一个线程,在那个网页上循环访问时让另一个线程自行运行。这样就避免了资源占用率过高的问题。(实现了资源的合理利用)

注意: 请求资源在大多数情况下并不涉及 CPU 的运算;其中 CPU 的参与度较低;而在我们的第一个示例中,在处理涉及数字运算时,在 for 循环中确实需要 CPU 来执行这些计算。


5.1 Thread 直接创建子线程

5.1.1 非守护线程

复杂的操作之前需要一个简单的示例开始:

复制代码
 import threading, timedef start():

    
     time.sleep(1)
    
     print(threading.current_thread().name) # 当前线程名称
    
     print(threading.current_thread().is_alive()) # 当前线程状态
    
     print(threading.current_thread().ident) # 当前线程的编号print('start')# 要使用多线程哪个函数>>>target=函数,name=给这个多线程取个名字# 如果你不起一个名字的话,那那它会自己去起一个名字的(pid)也就是个 ident# 类似声明thread = threading.Thread(target=start,name='my first thread')# 每个线程写完你不start()的话,就类似只是声明thread.start()
    
 print('stop')# 输出start
    
 stop
    
 my first threadTrue2968

如果有参数的话,我们就对多线程参数进行传参数。代码示例:

复制代码
 import threading, timedef start(num):

    
     time.sleep(num)
    
     print(threading.current_thread().name)
    
     print(threading.current_thread().isAlive())
    
     print(threading.current_thread().ident)
    
  
    
 print('start')
    
 thread = threading.Thread(target=start,name='my first thread', args=(1,))
    
  
    
 thread.start()
    
 print('stop')

解析:

我认认真看一下我们的运行结果,

startstopmy first threadTrue2968

我们会发现并不是按我们正常的逻辑执行这一系列的代码。

而不是,在完成 start 之后就会立即 stop ,只有当这一操作完成时才会有机会去执行我们函数的其他三个步骤。

该线程会深入透彻地完成整个流程。换句话说,在主线程执行完毕后才会处理其中的代码。

我们的代码并非等到线程启动(即调用 thread.start()) 并完成后再进行后续操作。相反,在线程启动之后(即调用 thread.start()) 后会立即继续后续操作,并会即时并行运行其内部的逻辑步骤(即该函数内部的操作)。并且永远不会因主线程的中断而停止运行。

由于程序运行至 print(‘stop’) 后即为主线程序终止状态, 其内部创建的子线程是我们自行启用了。

那些即便主线程序退出也不会被销毁掉的一类子线程称为非守护子线程。

  1. 主线程会跳过创建的线程继续执行;
  2. 直到创建线程运行完毕;
  3. 程序结束;

既然存在非守护线程,则必定存在相应的守护线程。无需着急,请允许我进一步举例说明。

通过 Thread 类我们可以创建一个线程,在创建过程中应指定 target 参数为其运行的方法名称;若被调用的方法需传递额外参数,则可通过 Thread 的 args 参数进行配置。

复制代码
 import threading, timedef target(second):

    
     print(f'Threading {threading.current_thread().name} is runing')
    
     print(f'Threading {threading.current_thread().name} sleep {second}s')
    
     time.sleep(second)
    
     print(f'Threading {threading.current_thread().name} ended')
    
  
    
 print(f'Threading {threading.current_thread().name} is runing')for i in [1, 5]:
    
     t = threading.Thread(target=target, args=[i])    # t = threading.Thread(target=target, args=(i,))
    
     t.start()
    
 print(f'Threading {threading.current_thread().name} is ended')# 输出Threading MainThread is runing
    
 Threading Thread-1 is runing
    
 Threading Thread-1 sleep 1s
    
 Threading Thread-2 is runing
    
 Threading Thread-2 sleep 5s
    
 Threading MainThread is ended
    
 Threading Thread-1 ended
    
 Threading Thread-2 ended

在本段中我们首先定义了一个名为 target 的方法。该方法接收名为 second 的参数。通过该方法的实现可以看出,这个方法本质上执行的是 time.sleep 休眠操作。其中 second参数即为休眠秒数,在其前后均会打印相关信息。具体而言,在主线程情况下(即当 threading.current_thread().name 等于 MainThread时),会打印对应的提示信息;而在子线程情况下(即当 threading.current_thread().name 形如 Thread-*时),也会相应地打印出相应的提示信息。

随后,在Thead类中我们创建了两个新的线程实例,并将它们分别命名为A和B以便后续区分。其中target参数指的是我们在上一步骤中所定义的特定方法名称,并通过该方法来执行相应的逻辑操作。args参数采用列表形式传递给这两个线程作为运行时所需的具体操作。在这两次循环过程中,变量i的值依次被赋值为1和5,并分别作为休眠时间来等待这两个线程完成当前的任务并进入下一步骤的操作状态。通过调用start方法即可启动这两个线程进行相应的操作。

通过观察可以发现,在本系统中总共生成了三个线程:其中包含一个主线程(MainThread)和两个子线程(Thread-1、Thread-2)。此外,在进一步分析中发现,在正常情况下主程序最先完成了运行过程,并依次使得两个从程序(Thread-1、Thread-2)紧跟其后完成任务。具体来说,在主程序完成任务后仅过了1秒时间另一个从程序开始执行任务并耗时4秒才完成自身的操作。这一现象与预期的同步机制设置存在一定的偏差

如果希望主线程序在所有子程序完成任务后才退出,则可以要求所有子程序执行join操作,请参考以下代码:

复制代码
 for i in [1, 5]:

    
     t = threading.Thread(target=target, args=[i])
    
     t.start()
    
     t.join()# 输出Threading MainThread is runing
    
 Threading Thread-1 is runing
    
 Threading Thread-1 sleep 1s
    
 Threading Thread-1 ended
    
 Threading Thread-2 is runing
    
 Threading Thread-2 sleep 5s
    
 Threading Thread-2 ended
    
 Threading MainThread is ended

这样,主线程必须等待子线程都运行结束,主线程才继续运行并结束。

5.2 继承 Thread 类创建子线程

另外,我们也可以通过继承 Thread 类的方式创建一个线程,该线程需要执行的方法写在类的 run 方法里面即可。上面的例子的等价改写为:

复制代码
 import threading, timeclass MyThread(threading.Thread):

    
     def __init__(self, second):
    
     threading.Thread.__init__(self)
    
     self.second = second    def run(self):
    
     print(f'Threading {threading.current_thread().name} is runing')
    
     print(f'Threading {threading.current_thread().name} sleep {self.second}s')
    
     time.sleep(self.second)
    
     print(f'Threading {threading.current_thread().name} is ended')
    
  
    
 print(f'Threading {threading.current_thread().name} is runing')for i in [1, 5]:
    
     t = MyThread(i)
    
     t.start()
    
     t.join()
    
 print(f'Threading {threading.current_thread().name} is ended')# 输出Threading MainThread is runing
    
 Threading Thread-1 is runing
    
 Threading Thread-1 sleep 1s
    
 Threading Thread-1 is ended
    
 Threading Thread-2 is runing
    
 Threading Thread-2 sleep 5s
    
 Threading Thread-2 is ended
    
 Threading MainThread is ended

可以看到,两种实现方式,其运行效果是相同的。

5.3 守护线程

在程序的执行过程中存在一种称为守护线程的机制,在这种机制下如果一个特定的线程被指定为守护状态当且仅当主线程序退出后该守护状态的维护就会被解除也就是说一旦主线程序退出且守护状态的维护对象尚未完成其任务则该对象将不再具备保护状态也就是说该守护状态的对象将被立即终止

如果要修改成守护线程,那你就得在 thread.start()前面加一个:

需要在我们启动之前设置。

示例一如下:

添加之前:

复制代码
 import threading, timedef start(num):

    
     time.sleep(num)
    
     print(threading.current_thread().name) # 当前线程的名字
    
     print(threading.current_thread().isAlive())
    
     print(threading.current_thread().ident)
    
  
    
 print('start') # 主线程开始thread = threading.Thread(target=start,name='my first thread', args=(1,))# 可以使用 for 循环来添加多个thread.start()
    
 print('stop') # 主线程结束# 运行结果start
    
 stop
    
 my first threadTrue15816

添加之后:

复制代码
 import threading, timedef start(num):

    
     time.sleep(num)
    
     print(threading.current_thread().name) # 当前线程的名字
    
     print(threading.current_thread().isAlive())
    
     print(threading.current_thread().ident)
    
  
    
 print('start') # 主线程开始thread = threading.Thread(target=start,name='my first thread', args=(1,))# 可以使用 for 循环来添加多个thread.setDaemon(True) # 在 start 开始之前设置thread.start()
    
 print('stop') # 主线程结束# 运行结果start
    
 stop

我们能够观察到:当程序启动时会调用 start 和 stop 函数,在 print('start') 和 print('stop') 这两个关键点上会触发特定的行为模式——打印信息后立即终止运行状态。这也意味着我们的守护线程会随之终止。无论主程序内部是否还有未执行的操作(例如 time.sleep()),一旦主程序完成关闭(即主线程退出),我们的守护线程也会随之终止并被销毁

我们日常启动的是非守护线程,守护线程用的较少。

守护线程会伴随主线程一起结束,setDaemon设置为 True 即可。

示例二如下:

添加之前:

复制代码
 import threading, timedef target(second):

    
     print(f'Threading {threading.current_thread().name} is runing')
    
     print(f'Threading {threading.current_thread().name} sleep {second}s')
    
     time.sleep(second)
    
     print(f'Threading {threading.current_thread().name} is ended')
    
  
    
 print(f'Threading {threading.current_thread().name} is runing')
    
 t1 = threading.Thread(target=target, args=[2])
    
 t1.start()
    
 t2 = threading.Thread(target=target, args=[5])
    
 t2.start()
    
 print(f'Threading {threading.current_thread().name} is ended')# 运行结果Threading MainThread is runing
    
 Threading Thread-1 is runing
    
 Threading Thread-1 sleep 2s
    
 Threading Thread-2 is runing
    
 Threading Thread-2 sleep 5s
    
 Threading MainThread is ended
    
 Threading Thread-1 is ended
    
 Threading Thread-2 is ended

添加之后:

复制代码
 import threading, timedef target(second):

    
     print(f'Threading {threading.current_thread().name} is runing')
    
     print(f'Threading {threading.current_thread().name} sleep {second}s')
    
     time.sleep(second)
    
     print(f'Threading {threading.current_thread().name} is ended')
    
  
    
 print(f'Threading {threading.current_thread().name} is runing')
    
 t1 = threading.Thread(target=target, args=[2])
    
 t1.start()
    
 t2 = threading.Thread(target=target, args=[5])
    
 t2.setDaemon(True)
    
 t2.start()
    
 print(f'Threading {threading.current_thread().name} is ended')# 运行结果Threading MainThread is runing
    
 Threading Thread-1 is runing
    
 Threading Thread-1 sleep 2s
    
 Threading Thread-2 is runing
    
 Threading Thread-2 sleep 5s
    
 Threading MainThread is ended
    
 Threading Thread-1 is ended

在当前操作中,我们采用了setDaemon()方法来将t2配置为守护线程.这样,当主线程的执行完成时,将会伴随着所有子线程的终结而自然退出.

运行结果:

复制代码
 Threading MainThread is runing

    
 Threading Thread-1 is runing
    
 Threading Thread-1 sleep 2s
    
 Threading Thread-2 is runing
    
 Threading Thread-2 sleep 5s
    
 Threading MainThread is ended
    
 Threading Thread-1 is ended

我们可以注意到,在主线程退出时自行终止了Thread-2,并且我们未曾观察到其打印退出的消息

值得注意的是,在当前情况下,并未直接调用 join方法;然而,在这种情况下,并未直接调用 join方法;即使t1和t2均执行join操作(即均进行join操作),主线程序必须等待所有子程序完成任务后才能退出

5.4 互斥锁

接下来是比较难的知识点,还是从简单的知识点开始。

例如,在当前程序中存在两个线程:一个负责增加一百万次数值;另一个负责减少一百万次数值。按照预期设计,执行完这两个操作后数值应为零。然而经过实际运行发现最终结果并不等于零值;多次运行后会发现每次得到的结果都不相同。具体实现代码如下:

复制代码
 import threadingimport time

    
  
    
 number = 0def addNumber(i):
    
     time.sleep(i)    global number    for i in range(1000000):
    
     number += 1
    
     print("加",number)def downNumber(i):
    
     time.sleep(i)    global number    for i in range(1000000):
    
     number -= 1
    
     print("减",number)
    
  
    
 print("start") # 输出一个开始thread = threading.Thread(target = addNumber, args=(2,)) #开启一个线程(声明)thread2 = threading.Thread(target = downNumber, args=(2,)) # 开启第二个线程(声明)thread.start() # 开始thread2.start() # 开始thread.join()
    
 thread2.join()# join 阻塞在这里,直到我们得阻塞线程执行完毕才会向下执行print("外", number)
    
 print("stop")

无论是在单线程环境下还是多线程环境下

复制代码
 import threadingimport time

    
  
    
 number = 0def addNumber(i = None):
    
     # time.sleep(i)
    
     global number    for i in range(1000000):
    
     number += 1
    
     print("加",number)def downNumber(i = None):
    
     # time.sleep(i)
    
     global number    for i in range(1000000):
    
     number -= 1
    
     print("减",number)
    
  
    
 addNumber()
    
 downNumber()
    
 print(number)# 运行结果加 1000000减 00# 反过来运行downNumber()
    
 addNumber()
    
 print(number)# 运行结果减 -1000000加 00# 再来一个差不多的例子:import threadingimport time
    
  
    
 number = 0def addNumber():
    
     global number    for i in range(1000000):
    
     number += 1
    
     print("加",number)    return numberdef downNumber():
    
     global number    for i in range(1000000):
    
     number -= 1
    
     print("减",number)    return number
    
  
    
 sum_num = downNumber() + addNumber()
    
 print("Result", sum_num)# 输出减 -1000000加 0Result -1000000# 修改以下代码,其他不变:sum_num = addNumber() + downNumber()# 输出加 1000000减 0Result 1000000

由上面的多线代码可以看出:两个线程对同一个数字进行操作。这导致的结果是该数字的状态变得无序。那么为何会出现这样的情况呢?

我们需要执行的一个操作是赋予变量新的值。具体来说,“number += 1”实际上等同于将当前数值加一并存储到number变量中。在Python编程语言中,在进行赋值之前,“number = number + 1”的计算步骤会先计算右边的结果然后将其分配给左边的变量。整个过程共分为两个步骤:首先计算右边表达式的值;其次将该结果赋予左边变量。

我先来看一下正确的运行流程:

复制代码
 # 我们的 number = 0

    
 # 第一步是先运行我们的代码:a = number + 1 
    
 # 等价于 0+1=1 # 也就是先运行右边的,然后赋值给 anumber = a 
    
 # 然后,再把 a 的结果赋值个 number
    
 # 上面运行完加法之后,我们加下来运行减肥的操作。b = number - 1 
    
 # 等价于 1-1 = 0
    
 # 然后,赋值个 number# 最后 number 等于 0number = 0

上面的过成是正确的流程,可在多线程里面呢?

复制代码
 number = 0

    
 # 开始初始值 0a = number+1 
    
 # 等价于 0+1=1
    
 # 这个地方要注意!!!
    
 # 在运行完上面一步的时候,还没来得急把结果赋值给 number
    
 # 就开始运行减法操作:b = number-1 
    
 # 等价于 0-1=-1
    
 # 然后,这两个运行结束之后就被赋值:number=b 
    
 # b = -1number=a 
    
 # a = 1# 最终得结果为:number = 1

原因是我们刚才的结果出现了错乱。即我们的计算与赋值虽然是两个部分,在这种情况下该多线程程序却无法按照顺序执行。这也正是我们常说的线程不安全性。

由于运行速度极快,在多个线程之间相互影响时导致系统出现这种错误。这是因为当两个或多个线程同时访问共享资源时会发生竞争。这也反映了程序中的多线程不安全性。

这就是说我们需要对 Lock 锁 进行加锁操作,在完成该操作后能够达到我们设定的 number 目标 。为了确保操作不出错,请务必完成加锁步骤后再进行后续操作。让我们先来了解加锁前的情况:接下来我们将介绍一个较为复杂的案例:

在一个进程中的多个线程是共享资源的

比如

在一个同一个进程中(注释:此处可选择是否保留注释),其中有一个全局变量 named 'count'用于计数(注释:如果保留注释,则应相应修改)。我们现在声明了多条独立的线程,在每条线程运行时都会给 count 增加数值(注:将"给"改为"都会给"使表述更加详细)。让我们验证一下这种设计的效果如何(注:"效果如何"换成"效果如何"),代码实现如下(注:此处可选择是否保留代码块)

复制代码
 import threading, time

    
  
    
 count = 0class MyThread(threading.Thread):
    
     def __init__(self):
    
     threading.Thread.__init__(self)    def run(self):
    
     global count
    
     temp = count + 1
    
     time.sleep(0.001)
    
     count = temp
    
 threads = []for _ in range(1000):
    
     thread = MyThread()
    
     thread.start()
    
     threads.append(thread)for thread in threads:
    
     thread.join()# print(len(threads))print(f'Final count: {count}')

在本节中, 我们创建了 1000 个线程实例, 每个线程都同步获取当前全局变量 count 的值, 短暂休眠后重新赋值该变量.

按照常规情况而言,在正常条件下预期 count 值应为 1000。然而实际上并非如此

运行结果如下:

复制代码
    Final count: 69

最后的结果居然只有 69,而且多次运行或者换个环境运行结果是不同的。

这是为什么呢?

由于count这个变量被多个线程共享使用,在任何在线执行temp=count操作时所有相关联的操作都能获取到当前count变量的值。然而,在某些情况下这些线程可能同时或以并行方式运行从而可能导致不同线程获取相同的count变量值最终会导致部分线程在进行count加1操作时这一操作并未真正生效从而使最终的结果出现偏差。

这意味着当多个线程同时对某个数据进行访问或修改时可能会产生出乎意料的结果。为了防止这种情况发生我们需要确保多线程操作能够同步完成一种方法是对涉及的操作数据施加锁机制这里自然要用到 threading.Lock 机制。

加锁保护是什么意思呢?

在对数据进行操作之前加锁,在操作数据之前加锁,在操作数据之前需要先加锁;其他线程发现已被加锁后无法继续执行;只有加锁的线程释放锁定之后才能继续对数据进行修改;修改完成后必须释放锁定;这样可以确保同一时间只有一个线程能对数据进行操作;多个线程就不会同时读取和修改同一个数据;最终运算结果正确。

我们可以将代码修改为如下内容:

示例一的修改:

复制代码
 import threadingimport time

    
  
    
 lock = threading.Lock() # 创建一个最简单的 读写锁number = 0def addNumber():
    
     global number    for i in range(1000000):
    
     lock.acquire() # 先获取
    
     number += 1
    
     # 中间的这个过程让他强制有这个计算和赋值的过程,也就是让他执行完这两个操作,后再切换。
    
     # 这样就不会完成计算后,还没来的及赋值就跑到下一个去了。
    
     # 这样也就防止了线程不安全的情况
    
     lock.release() # 再释放def downNumber():
    
     global number    for i in range(1000000):
    
     lock.acquire()
    
     number -= 1
    
     lock.release()
    
  
    
 print("start") # 输出一个开始thread = threading.Thread(target = addNumber) #开启一个线程(声明)thread2 = threading.Thread(target = downNumber) # 开启第二个线程(声明)thread.start() # 开始thread2.start() # 开始thread.join()
    
 thread2.join()# join 阻塞在这里,直到我们得阻塞线程执行完毕才会向下执行print("外", number)
    
 print("stop")# 输出start
    
 外 0stop

在代码块**lock.acquire() 与 lock.release()**之间的这一系列步骤迫使该线程完成所有计算和赋值操作后才进行切换。从而确保只有在完成这两个操作之后才会切换到下一个线程。这也就避免了由于提前完成计算却未及时赋值而跳转到下一个线程的情况。这样也就防止了线程不安全的情况。

然后是我们的第一个线程获取 lock.acquire() 的过程;接着是第二个线程会被阻塞于 lock.acquire() 直到我们的第二个线程释放 lock.release() ,此时它才能占有该锁并开始执行;如此反复交替进行。

死锁: 当一个线程获取了锁之后,在完成任务后未将其返回给其他线程使用的情况下(简单来说),后续的线程会持续等待前者释放 lock(就像互为镜像),直到双方都完成自己的操作为止(也就是在没有 release 的情况下)。

示例二的加锁

复制代码
 import threading, time

    
  
    
 count = 0class MyThread(threading.Thread):
    
     def __init__(self):
    
     threading.Thread.__init__(self)    def run(self):
    
     global count
    
     lock.acquire() # 获取锁
    
     temp = count + 1
    
     time.sleep(0.001)
    
     count = temp
    
     lock.release() # 释放锁lock = threading.Lock()
    
 threads = []for _ in range(1000):
    
     thread = MyThread()
    
     thread.start()
    
     threads.append(thread)for thread in threads:
    
     thread.join()
    
 print(f'Final count: {count}')

我们在此处声明了一个lock对象;这个lock对象其实就是threading.Lock的一个实例;在run方法内部,在获取count之前先加锁;完成count的修改后立即释放该lock

运行结果如下:

复制代码
    Final count: 1000

这样运行结果就正常了。

关于 Python 的多线程相关内容,这里暂且先介绍基础概念。关于 threading 更多细节,如信号量、队列等的具体实现与应用方法,请参考官方文档:https://docs.python.org/zh-cn/3.7/library/threading.html#module-threading。

5.5 递归锁 RLOCK

再次利用同一个锁内可以嵌套另一个锁。对于普通的单线程锁,在同一个线程内最多只能获取一次资源。如果试图进行第二次操作则会触发错误。

递归锁什么时候用呢?需要更低精度的,力度更小,为了更小的力度。

复制代码
 import threadingimport timeclass Test:

    
     rlock = threading.RLock()    def __init__(self):
    
     self.number = 0
    
  
    
     def execute(self, n):
    
     # 原本是获取锁和释放锁,那如果有时候你忘记了写 lock.release() 那就变成了死锁。
    
     # 而 with 可以解决这个问题。
    
     with Test.rlock:            # with 内部有个资源释放的机制
    
         self.number += n    def add(self):
    
     with Test.rlock:
    
         self.execute(1)    def down(self):
    
     with Test.rlock:
    
         self.execute(-1)def add(test):
    
     for i in range(1000000):
    
     test.add()def down(test):
    
     for i in range(1000000):
    
     test.down()if __name__ == '__main__':
    
     thread = Test() # 实例化
    
     t1 = threading.Thread(target=add, args=(thread,))
    
     t2 = threading.Thread(target=down, args=(thread,))
    
     t1.start()
    
     t2.start()
    
     t1.join()
    
     t2.join()
    
     print(t.number)

我们发现这种递归锁在运行过程中会产生较大的性能开销;这也意味着,在获取和释放 lock 的过程中需要进行上下文切换操作;因此,在开启大量的 recursive locks 时会导致系统资源消耗增加进而使得程序运行速度减缓;对于大型项目来说通常不会配置过多 recursive locks 这不仅是因为其运行效率问题也是为了避免潜在性能瓶颈的影响。

5.6 Python 多线程的问题

受限于Python中的GIL机制,在单核或多核环境下,在同一时间只能执行一个线程;这使得Python在多线程场景下未能充分展现其多核并行的能力

GIL通称全局解释器锁(Global Interpreter Lock),中文译作全局解释器锁(Global Interpreter Lock),其最初目的是为了数据安全考量。

在 Python 多线程下,每个线程的执行方式如下:

  • 获取 GIL
  • 执行对应线程的代码
  • 释放 GIL

可以看出,在Python中某个线程想要执行时必须先获取GIL,并将其视为通行令牌使用。在一个Python进程中只有一个GIL可用。如果无法获得该令牌,则该线程将被阻止并无法运行任何操作。在多核环境下运行多个Python进程时,在同一时间每个进程只能执行一个线程

然而针对基于爬虫的应用程序这类 I/O 密集型任务而言,在这个问题上带来的影响较小。相反地,在处理计算密集型的应用程序时,则会受到 GIL 的限制,在多线程的整体运行效率方面可能低于单线程水平。

全部评论 (0)

还没有任何评论哟~