前情提要
在计算中,进程是正在执行的计算机程序的一个实例。任何流程都有 3 个基本组成部分:
- 一个可执行程序。
- 程序所需的相关数据(变量、工作空间、缓冲区等)
- 程序的执行上下文(进程状态)
- 线程是进程中可以调度执行的实体。此外,它是可以在 OS(操作系统)中执行的最小处理单元。
简而言之,线程是程序中的一系列此类指令,可以独立于其他代码执行。为简单起见,可以假设线程只是进程的子集。
线程在线程控制块 (TCB) 中包含所有这些信息:
- 线程标识符:为每个新线程分配唯一 id (TID)
- 堆栈指针:指向进程中线程的堆栈。堆栈包含线程范围内的局部变量。
- 程序计数器:存放线程当前正在执行的指令地址的寄存器。
- 线程状态:可以是running、ready、waiting、start或done。
- 线程的寄存器集:分配给线程进行计算的寄存器。
- 父进程指针:指向线程所在进程的进程控制块 (PCB) 的指针。
多线程
一个进程中可以存在多个线程,其中:
- 每个线程都包含自己的寄存器集和局部变量(存储在堆栈中)。
- 一个进程的所有线程共享全局变量(存储在堆中)和程序代码。
思考一下下图,内存中多个线程是如何存在的: 多线程被定义为处理器同时执行多个线程的能力。
实现
一、线程基础
1. 简易demo
import threading
def print_cube(num):
print("Cube: {}".format(num * num * num))
def print_square(num):
print("Square: {}".format(num * num))
if __name__ == "__main__":
t1 = threading.Thread(target=print_square, args=(10,))
t2 = threading.Thread(target=print_cube, args=(10,))
t1.start()
t2.start()
t1.join()
t2.join()
print("Done!")
输出:
Square: 100
Cube: 1000
Done!
解释上面的demo:
-
要导入线程模块: import threading
-
要创建一个新线程,必须创建一个Thread类的对象。它的参数有:
- target : 线程要执行的函数
- args:要传递给目标函数的参数
在上面的示例中,我们创建了 2 个具有不同目标函数的线程: t1 = threading.Thread(target=print_square, args=(10,))
t2 = threading.Thread(target=print_cube, args=(10,))
-
要启动一个线程,我们使用Thread类的start方法。 t1.start()
t2.start()
-
一旦线程启动,当前程序(你可以把它想象成一个主线程)也会继续执行。使用join方法,让主进程等待子进程运行结束后再运行。 t1.join()
t2.join()
思考下图,以便更好地理解上述程序的工作原理:
线程运行
2. 如何更改线程的名称
import threading
import os
def task1():
print("Task 1 线程名称: {}".format(threading.current_thread().name))
print("task 1 所属进程的ID: {}".format(os.getpid()))
def task2():
print("Task 2 线程名称: {}".format(threading.current_thread().name))
print("task 2 所属进程的ID: {}".format(os.getpid()))
if __name__ == "__main__":
print("主进程ID: {}".format(os.getpid()))
print("主线程名称: {}".format(threading.main_thread().name))
t1 = threading.Thread(target=task1, name='t1')
t2 = threading.Thread(target=task2, name='t2')
t1.start()
t2.start()
print("主进程结束")
输出:
主进程ID: 11758
主线程名称: MainThread
Task 1 线程名称: t1
task 1 所属的进程ID: 11758
Task 2 线程名称: t2
task 2 所属的进程ID: 11758
主进程结束
上述demo解析:
- os.getpid() 函数来获取当前进程的 ID
print("主进程ID: {}".format(os.getpid()))
可以看出子线程运行时的主进程ID是不变的。 - threading.main_thread() 函数来获取主线程对象。在正常情况下,主线程是启动 Python 解释器的线程。线程对象的name属性用于获取线程的名称。
print("主线程名称: {}".format(threading.main_thread().name))
- threading.current_thread() 函数来获取当前线程对象。
print("Task 1 线程名称: {}".format(threading.current_thread().name))
3. 线程中使用Logging模块
我们能够通过打印线程名来识别当前运行的是哪个线程。然而,我们真正需要的是Logging模块的支持,该模块将使用的 %(threadName)s可以将线程名嵌入到每个日志消息中。在日志消息中包含线程名可以更容易地将这些消息追溯到其源。注意,Logging是线程安全的,因此来自不同线程的消息在输出中保持不同。
import threading
import time
import logging
logging.basicConfig(level=logging.DEBUG,
format='[%(levelname)s] (%(threadName)-9s) %(message)s',)
def f1():
logging.debug('开始')
time.sleep(1)
logging.debug('结束')
def f2():
logging.debug('开始')
time.sleep(2)
logging.debug('结束')
def f3():
logging.debug('开始')
time.sleep(3)
logging.debug('结束')
t1 = threading.Thread(target=f1)
t2 = threading.Thread(name='f2', target=f2)
t3 = threading.Thread(name='f3', target=f3)
t1.start()
t2.start()
t3.start()
输出
[DEBUG] (Thread-1 ) 开始
[DEBUG] (f2 ) 开始
[DEBUG] (f3 ) 开始
[DEBUG] (Thread-1 ) 结束
[DEBUG] (f2 ) 结束
[DEBUG] (f3 ) 结束
4. 守护线程 setDaemon
守护线程是在后台运行并为主线程或非守护线程提供支持,一般那些后台执行的线程被视为守护线程。守护线程不会阻止主线程退出。 它的最佳示例之一是垃圾收集器,因为我们假设主线程正在执行或运行,此时如果发生任何内存问题, python 虚拟机(PVM)将立即执行垃圾收集器。垃圾收集器将在后台执行并销毁所有无用的对象,然后释放出空闲内存,一旦有空闲内存可用,主线程会将毫无问题地执行。
(1) 非守护线程不会在主线程结束后停止运行
from threading
import time
def thread_1():
for i in range(5):
print('这是非守护进程')
time.sleep(2)
T = Thread(target=thread_1)
T.start()
time.sleep(5)
print('主线程结束')
输出:
这是非守护进程
这是非守护进程
这是非守护进程
主线程结束
这是非守护进程
这是非守护进程
(2) 将子线程设置为守护线程
- obj.setDaemon():参数 True/False
from threading
import time
def thread_1():
for i in range(5):
print('这是非守护进程')
time.sleep(2)
T = Thread(target=thread_1)
T.setDaemon(True)
T.start()
time.sleep(5)
print('主线程结束')
输出
这是非守护进程
这是非守护进程
这是非守护进程
主线程结束
(3) 否是守护线程
注意:
obj.setDaemon() 设置线程为守护线程时,一定要在 obj.start() 前。因为活动的线程无法设置为守护线程。否则会报错:RuntimeError: cannot set daemon status of active thread
5. 加入线程 join
此方法可以理解为:在主线程的执行顺序里加入一个子线程,这时,子线程就占用了主线程的执行时间,必须等待子线程结束后才能继续执行主线程的后续代码。 在调用 join() 方法时,调用线程被阻塞,直到线程对象停止运行。线程对象可以在以下任何一种情况下终止:
语法:
object_name.join()
object_name.join(timeout)
6. 主线程与子线程的关系
下图介绍了主线程与子线程的运行关系:
二、线程同步
线程同步被定义为一种机制,它确保多个并发线程不会同时执行临界区的代码段。
临界区是指访问共享资源的程序部分。
1. 为什么需要同步
import threading
x = 0
def increment():
global x
x += 1
def thread_task():
for _ in range(100000):
increment()
def main_task():
global x
x = 0
for _ in range(2):
t = threading.Thread(target=thread_task)
t.start()
t.join()
if __name__ == "__main__":
for i in range(10):
main_task()
print("迭代 {0}: x = {1}".format(i,x))
输出
迭代 0: x = 175005
迭代 1: x = 200000
迭代 2: x = 200000
迭代 3: x = 169432
迭代 4: x = 153316
迭代 5: x = 200000
迭代 6: x = 167322
迭代 7: x = 200000
迭代 8: x = 169917
迭代 9: x = 153589
在上面的程序中:
- 在main_task函数中创建了两个线程t1和t2 ,并将全局变量x设置为 0。
- 每个线程都有一个目标函数thread_task函数,每个threat_task函数被调用100000次。
- thread_task函数将在每次调用中将全局变量x增加 1。
x的预期结果应该是200000,实际结果在10 次迭代中的值并不是200000。 这是由于线程对共享变量x的并发访问而发生的。x值的这种不可预测性是由于线程竞争造成的,这种情况也被称为线程不安全。
线程竞争
因此,我们需要一个工具来在多个线程之间进行适当的同步。
2. Lock 线程锁
threading模块提供了一个Lock类来处理竞争条件。Lock是使用操作系统提供的Semaphore(信号量)对象实现的。
Semaphore信号量是一个同步对象,它控制并行编程环境中多个进程/线程对公共资源的访问。它只是操作系统(或内核)存储中指定位置的一个值,每个进程/线程都可以检查并更改它。根据找到的值,进程/线程可以使用该资源,如果检查它已经被使用,就必须等待一段时间才能再次尝试。信号量可以是二进制的(0或1),也可以有额外的值。通常,使用信号量的进程/线程会检查该值,然后,如果它使用该资源,就会更改该这个值,以便后续信号量的使用者知道要等待。
Lock类提供以下方法:
小demo:
import threading
x = 0
def increment():
global x
x += 1
def thread_task(lock):
for _ in range(100000):
lock.acquire()
increment()
lock.release()
def main_task():
global x
x = 0
lock = threading.Lock()
t1 = threading.Thread(target=thread_task, args=(lock,))
t2 = threading.Thread(target=thread_task, args=(lock,))
t1.start()
t2.start()
t1.join()
t2.join()
if __name__ == "__main__":
for i in range(10):
main_task()
print("迭代 {0}: x = {1}".format(i,x))
结果
迭代 0:x = 200000
迭代 1:x = 200000
迭代 2:x = 200000
迭代 3:x = 200000
迭代 4:x = 200000
迭代 5:x = 200000
迭代 6:x = 200000
迭代 7:x = 200000
迭代 8:x = 200000
迭代 9:x = 200000
- 解释上面的demo:
首先,使用以下命令创建一个Lock对象:lock = threading.Lock()
- 然后,lock对象作为参数传入函数:
t1 = threading.Thread(target=thread_task, args=(lock,))
t2 = threading.Thread(target=thread_task, args=(lock,))
- 在目标函数的关键部分,使用lock.acquire()方法应用锁。一旦获得锁,在使用lock.release()方法释放锁之前,没有其他线程可以访问临界区(这里是increment函数) 。
lock.acquire()
increment()
lock.release()
结果正是我们想要的,每次x的值都是 200000。 流程如下: ![在这里插入图片描述](https://img-blog.csdnimg.cn/86d3810962fe4568a386abcdeb346e01.png?x-oss-process=image/watermark,type_d3F5LXplbmhlaQ,shadow_50,text_Q1NETiBA5aSn5L2s5qmZMTIxNQ==,size_16,color_FFFFFF,t_70,g_se,x_16,#pic_center
三、线程通信
1. 队列Queue
#TODO 待完善
2. 条件Condition
#TODO 待完善
四、多线程的优点及缺点
优点 | 缺点 |
---|
1、线程之间彼此独立。 2、由于线程并行执行任务,因此可以更好地利用系统资源。 3、增强单核多处理器机器的性能。 | 1、随着线程数的增加,程序复杂性也会增加。 2、操作共享资源(对象、数据)时,必须进行同步。 3、调试难度高,结果有时不可预测。 4、程序设计糟糕的话会导致死锁。 5、只适用于IO密集型 |
|