全部文章

任务、线程与进程(多线程,多进程)

关于多进程多线程的深入理解先通读:

彻底搞懂:任务、进程、线程(多进程多线程究竟是如何提高程序执行效率的)

多任务

多任务的两种表现形式

并发

在一段时间内交替执行多个任务;

例如:在单核cpu处理多任务,操作系统轮流让各个任务交替执行。

并行

在一段时间内真正同时执行多个任务;

例如:对于多核cpu处理多任务,操作系统会给cpu的每个内核安排一个执行的任务,多个内核是真正的一起同时执行多个任务。这里需要注意多核cpu是并行的执行多任务,始终有多个任务一起执行。

 因此,多任务是可以提高CPU的利用率的。

进程

程序中实现多任务

在Python、中实现多任务需要通过多进程。 

进程的概念

进程是资源分配的最小单位,它是操作系统进行资源分配和调度运行的基本单位。

通俗解释:一个正在运行的程序就是一个进程,例如QQ、微信、谷歌浏览器:

 操作系统为每个进程分配:CPU,内存,磁盘,网络等等资源:

 注意:一个运行中的程序至少有一个进程(程序打开就会创建一个主进程),也可以有多个:

  • 进程展开后显示的是子进程,它们是独立的进程。

  • 子进程 ≠ 线程,线程是进程内部的执行单元,不会以子进程形式显示。

  • 子进程是由父进程直接或间接启动的独立进程。例如:

    • 浏览器(如 Chrome)可能为每个标签页或插件创建多个子进程。

    • 某些应用程序(如安装程序或开发工具)可能启动后台服务或其他辅助进程。

  • 子进程
    •   是独立的进程,拥有独立的内存空间、资源和 PID(进程标识符)

    •   由父进程通过 CreateProcess 等系统调用创建。

多进程介绍

多进程作用

思考:
图中是一个非常简单的程序,一旦运行hello.py这个程序,按照代码的执行顺序,func a函数执行完毕后才能执行func b函数.如果可以让func a和func b同时运行,显然执行hello.py这个程序的效率会大大提升.

单进程创建与执行

# 导入进程包import time
# 唱歌
def sing():
    for i in range(3):
        print("唱歌")
        time.sleep(1)
# 跳舞
def dance():
    for i in range(3):
        print("跳舞")
        time.sleep(1)
# 单任务模式
if __name__ == '__main__':
    sing()
    dance()
'''
运行上述代码打印结果:
唱歌
唱歌
唱歌
跳舞
跳舞
跳舞
'''

多进程的创建步骤

1.导入进程包

import multiprocessing

2.通过进程类创建进程对象

#进程对象 = multiprocessing.Process([group [, target [, name [, args [, kwargs]]]]])
process1 = multiprocessing.Process([group [, target [, name [, args [, kwargs]]]]])
process12 = multiprocessing.Process([group [, target [, name [, args [, kwargs]]]]])
"""
源码:
    def __init__(self, group=None, target=None, name=None, args=(), kwargs={},*, daemon=None)

参数说明:
        target:要执行的函数名(方法名)
        name:进程名,一般不用设置,默认是Process-n
        group:目前只能为None
        args:函数的参数,注意为元组类型("a","b")
        kwargs:字典类型的参数
        daemon:是否守护进程
"""

3.启动进程执行任务

#进程对象.start()
process1.start()
process2.start()

示例代码:

import multiprocessing
import time
# 唱歌
def sing():
    for i in range(3):
        print("唱歌")
        time.sleep(1)
# 跳舞
def dance():
    for i in range(3):
        print("跳舞")
        time.sleep(1)

if __name__ == '__main__':
    process1 = multiprocessing.Process(target=sing)
    process2 = multiprocessing.Process(target=dance)
    process1.start()
    process2.start()
'''
程序执行打印结果:可以看出“唱歌”和“跳舞”是同时执行的
唱歌
跳舞
唱歌
跳舞
唱歌
跳舞
'''

 进程执行带有参数的任务

import multiprocessing
import time

# 唱歌
def sing(num,songName,type):
    for i in range(num):
        print("演唱:",type,"音乐",songName,i+1,"")
        time.sleep(1)
# 跳舞
def dance(num):
    for i in range(num):
        print("跳舞",i+1,"")
        time.sleep(1)

if __name__ == '__main__':
    process1 = multiprocessing.Process(target=sing,args=(3,),kwargs={"songName":"老鼠爱大米","type":"流行"})
    # process1 = multiprocessing.Process(None,sing,None,(3,),{"songName":"老鼠爱大米"})#这里传参如果不使用关键字传参,就必须按照顺序把所有参数都传进去
    process2 = multiprocessing.Process(target=dance,args=(3,))
    process1.start()
    process2.start()
'''
演唱: 老鼠爱大米 1 次
跳舞 1 次
演唱: 老鼠爱大米 2 次
跳舞 2 次
演唱: 老鼠爱大米 3 次
跳舞 3 次
'''

获取进程编号

进程编号的作用:
当程序中进程的数量越来越多时,如果没有办法区分主进程和子进程还有不同的子进程,那么就无法进行有效的进程管理,为了方便管理实际上每个进程都是有自己编号的.

 获取当前进程的编号

获取当前进程父级进程编号

import multiprocessing
import os
import time

# 唱歌
def sing(num,songName,type):
    print("唱歌进程的pid:", os.getpid())
    print("唱歌进程的父进程pid:", os.getppid())
    for i in range(num):
        print("演唱:",type,"音乐",songName,i+1,"")
        time.sleep(1)
# 跳舞
def dance(num):
    print("跳舞进程的pid:", os.getpid())
    print("跳舞进程的父进程pid:", os.getppid())
    for i in range(num):
        print("跳舞",i+1,"")
        time.sleep(1)

if __name__ == '__main__':
    print("主进程的pid:", os.getpid())
    process1 = multiprocessing.Process(target=sing,args=(3,),kwargs={"songName":"老鼠爱大米","type":"流行"})
    process2 = multiprocessing.Process(target=dance,args=(3,))
    process1.start()
    process2.start()
'''
主进程的pid: 21900
唱歌进程的pid: 5812
唱歌进程的父进程pid: 21900
演唱: 流行 音乐 老鼠爱大米 1 次
跳舞进程的pid: 16924
跳舞进程的父进程pid: 21900
跳舞 1 次
演唱: 流行 音乐 老鼠爱大米 2 次
跳舞 2 次
演唱: 流行 音乐 老鼠爱大米 3 次
跳舞 3 次
'''

守护进程

默认情况下,主进程会等待所有子进程结束之后才结束,但是很多时候我们需要在关闭主进程的时候,同时关闭所有子进程,那么就需要使用守护线程:

import multiprocessing
import time

def worker():
    for i in range(10):
        print("woring:",i+1)
        time.sleep(0.2)
if __name__ == '__main__':
    process = multiprocessing.Process(target=worker)
    # 设置守护主进程,主进程结束,子进程自动销毁
    process.daemon=True
    process.start()
    time.sleep(1)
    print("主进程执行完了!!!")
'''
未设置是守护进程的打印内容:主进程执行完之后,子进程依然在执行
woring: 1
woring: 2
woring: 3
woring: 4
woring: 5
主进程执行完了!!!
woring: 6
woring: 7
woring: 8
woring: 9
woring: 10

设置守护进程之后的打印内容:主进程执行完之后,子进程立即停止
woring: 1
woring: 2
woring: 3
woring: 4
woring: 5
主进程执行完了!!!
'''

进程案例-文件夹高并发拷贝

import multiprocessing
import os
import shutil

# 定义拷贝方法
def copyFile(item, sourceDir, targetDir):  # 将 sourceDir 和 targetDir 作为参数传给 copyFile 函数,避免依赖全局变量(多进程中全局变量可能无法正确共享)。
    try:
        # 路径拼接方法1:
        # shutil.copyfile(sourceDir+"/"+item,targetDir+"/"+item)
        # 路径拼接方法2:使用 os.path.join 替代手动拼接路径,避免操作系统差异(如 Windows 的 \ 和 Linux 的 /)。
        shutil.copyfile(os.path.join(sourceDir, item), os.path.join(targetDir, item))
    # 处理单个文件拷贝失败的情况:
    except Exception as e:
        print(f"拷贝{item}出错:", e)

if __name__ == "__main__":
    # 1.定义源文件夹和目标文件夹
    sourceDir = r"D:\learn\AI产品经理\2、人工智能全套路线-北风网\1、数学基础(1)"
    targetDir = r"C:\Users\luzhanshi\Desktop\fileCopyTest"
    # 2.创建目标文件夹
    try:
        os.mkdir(targetDir)
    except FileExistsError:
        print("文件夹已存在,不需要创建")
    # 3.读取源文件文件列表
    listdir = os.listdir(sourceDir)
    # 4.使用多进程任务实现拷贝
    processes = []
    for item in listdir:
        process = multiprocessing.Process(target=copyFile, args=(item, sourceDir, targetDir))
        process.start()
        print("开始拷贝:", item)
        processes.append(process)
    # 等待所有子进程完成:添加 process.join() 确保主进程等待所有子进程完成后再退出。
    for process in processes:
        process.join()
    print("拷贝完成!")

多进程的几种方法

multiprocessing提供了threading包中没有的IPC(比如Pipe和Queue),效率上更高。应优先考虑Pipe和Queue,避免使用Lock/Event/Semaphore/Condition等同步方式 (因为它们占据的不是用户进程的资源,而是线程)。

  • Lock:可以避免访问资源时的冲突
  • Pool:可以提供指定数量的进程
  • Queue:多进程安全的队列,实现多进程之间的数据传递
  • Pipe:实现管道模式下的消息发送与接收

lock(互斥锁)

同步与异步

同步执行:一个进程在执行任务时,另一个进程必须等待执行完毕,才能继续执行,加锁可以保证多个进程修改同一块数据时,同一时间只能有一个任务可以进行修改.没错,速度是慢了,但牺牲了速度却保证了数据安全。

异步执行:一个进程在执行任务时,另一个进程无需等待其执行完毕就可以执行,当有消息返回时,系统会提醒后者进行处理,这样会很好的提高运行效率.

import multiprocessing
import time

def work1(f, n, lock):
    with open(f, "a", encoding="utf-8") as w:
        try:
            with lock:
                for i in range(n):
                    print(f"work1开始写{i + 1}")
                    w.write("我是work1这是我写的内容\n")
                    print(f"work1停止{i + 1}")
                    time.sleep(0.005)
        except Exception as e:
            print(f"第{i}次写入出错了:{e}")

def work2(f, n, lock):
    try:
        with open(f, "a", encoding="utf-8") as w:
            lock.acquire()
            for i in range(n):
                print(f"work2开始写{i + 1}")
                w.write("我是work2这是我写的内容\n")
                print(f"work2停止{i + 1}")
                time.sleep(0.005)
    except Exception as e:
        print(f"第{i}次写入出错了:{e}")
    finally:
        lock.release()

if __name__ == "__main__":
    f = './tem/test34.txt'
    lock = multiprocessing.Lock()
    process1 = multiprocessing.Process(target=work1, args=(f, 3, lock))
    process2 = multiprocessing.Process(target=work2, args=(f, 3, lock))

    process1.start()
    process2.start()
    process1.join()
    process2.join()
    print("写完啦...")
with lock: 和显式的 lock.acquire()/lock.release()

在 Python 中,with lock: 和显式的 lock.acquire()/lock.release() 都可以用于管理线程锁,但它们在安全性代码简洁性异常处理方面存在显著差异。以下是详细对比和选择建议:


1. 基础等价性

二者核心功能相同,均用于实现锁的获取和释放

# 显式调用方式
lock.acquire()
try:
    # 临界区代码
finally:
    lock.release()

# with 语句方式(等价)
with lock:
    # 临界区代码

2. 核心区别

特性with lock:显式 acquire()/release()
异常安全 自动释放锁,即使发生异常或代码中途退出 需手动 try...finally 确保释放锁
代码简洁性 上下文管理器自动管理,代码更简洁 需要显式调用,代码冗余
可读性 逻辑清晰,直接表明临界区范围 锁的获取/释放可能分散在代码中
容错性 强制避免忘记释放锁 易因遗漏 release() 导致死锁
适用场景 绝大多数常规场景 需要非阻塞尝试获取锁(如 blocking=False

3. 安全性对比

示例 1:异常场景下的锁释放

# with 语句:绝对安全
with lock:
    risky_operation()  # 若此处抛出异常,锁仍会自动释放

# 显式调用:需手动容错
lock.acquire()
try:
    risky_operation()
finally:
    lock.release()  # 必须确保执行

示例 2:忘记释放锁

# 错误写法:未释放锁(导致死锁)
lock.acquire()
do_something()
# 忘记写 lock.release()

# 正确写法:with 语句自动释放
with lock:
    do_something()

4. 性能差异

  • 无显著区别with lock: 的上下文管理器机制底层依然是调用 acquire() 和 release(),性能开销可忽略。

  • 优化建议:优先关注代码安全性,而非微小的性能差异。


5. 何时必须使用显式调用?

在以下场景中,需使用 acquire()/release()

  • 非阻塞获取锁:尝试获取锁,若失败则执行其他操作。

    if lock.acquire(blocking=False):  # 非阻塞模式
        try:
            # 临界区代码
        finally:
            lock.release()
    else:
        print("锁被占用,执行备用逻辑")

6. 终极选择建议

场景推荐方式
常规锁操作(99% 场景) with lock:
需要非阻塞尝试获取锁 显式 acquire()
需要复杂锁管理(如嵌套锁) 显式调用(谨慎使用)

总结

  • 默认选择 with lock::代码简洁、安全、易维护。

  • 特殊需求用显式调用:如非阻塞获取锁或复杂锁逻辑。

  • 永远不要省略异常处理:若用显式调用,必须配合 try...finally 确保释放锁。

Pool(进程池)

Pool可以提供指定数量的进程,供用户调用,当有新的请求提交到pool中时,如果池还没有满,那么就会创建一个新的进程用来执行该请求;但如果池中的进程数已经达到规定最大值,那么该请求就会等待,直到池中有进程结束,才会创建新的进程来执行它。

传统方法使用线程池:

import multiprocessing
import os
import time


def work(n):
    print(f"run work {n},work id:{os.getpid()}")
    time.sleep(5)
    print(f"run work {n},work id:{os.getpid()}")


if __name__ == '__main__':
    print(f"父进程ID:{os.getpid()}")
    # 创建进程池
    pool = multiprocessing.Pool(3)
    for i in range(5):
        # apply(func[, args[, kwds]]): 阻塞的执行,比如创建一个有3个线程的线程池,当执行时是创建完一个,执行完函数再创建另一个,变成一个线性的执行.
        # pool.apply(work, args=(i,))
        # apply_async(func[, args[, kwds[, callback]]]) : 它是非阻塞执行,同时创建3个线程的线城池,同时执行,只要有一个执行完立刻放回池子待下一个执行,并行的执行 .
        pool.apply_async(work, args=(i,))
    # close(): 关闭pool,使其不在接受新的任务。  pool.terminate() : 结束工作进程,不在处理未完成的任务。
    pool.close()
    # join(): 主进程阻塞,等待子进程的退出, join方法要在close或terminate之后使用。
    pool.join()

较新方式:

if __name__ == '__main__':
    print(f"👨💻 父进程ID: {os.getpid()}")
    
    # 使用上下文管理器自动管理进程池(Python 3.3+)
    with multiprocessing.Pool(processes=3) as pool:
        # 使用列表推导式预生成所有任务
        tasks = [
            pool.apply_async(
                func=work,
                args=(i,),
                error_callback=error_callback
            )
            for i in range(5)
        ]
        
        # 可选:添加进度监控
        while not all(task.ready() for task in tasks):
            time.sleep(0.1)
    
    print("✅ 所有任务已完成")  # 自动触发 pool.join()

再次简化代码:

if __name__ == '__main__':
    print(f"👨💻 父进程ID: {os.getpid()}")
    
    with multiprocessing.Pool(processes=3) as pool:
        # 使用 map 方法提交任务
        results = pool.map(work, range(5))#这里的range是可迭代对象,每个元素会作为`work`的参数。`pool.map(work, range(5))`实际上会将0到4依次作为`n`的值传给`work`,每个进程调用一次`work(i)`,其中i是range中的元素。
    
    # 检查所有任务是否成功
    if all(results):
        print("✅ 所有任务均成功完成")
    else:
        print(f"⚠️ 有 {results.count(False)} 个任务失败")

Queue

进程彼此之间互相隔离,要实现进程间通信(IPC),multiprocessing模块支持两种形式:队列Queue和管道Pipe,这两种方式都是使用消息传递的

创建队列的类(底层就是以管道和锁定的方式实现): Queue([maxsize]):创建共享的进程队列,Queue是多进程安全的队列,可以使用Queue实现多进程之间的数据传递。      

   参数介绍:maxsize是队列中允许最大项数,省略则无大小限制。

Queue介绍方法

  • q.put方法用以插入数据到队列中:

   put方法还有两个可选参数:blocked和timeout。如果blocked为True(默认值),并且timeout为正值,该方法会阻塞timeout指定的时间,直到该队列有剩余的空间。如果超时,会抛出Queue.Full异常。如果blocked为False,但该Queue已满,会立即抛出Queue.Full异常。

  • q.get方法可以从队列读取并且删除一个元素。

   同样,get方法有两个可选参数:blocked和timeout。如果blocked为True(默认值),并且timeout为正值,那么在等待时间内没有取到任何元素,会抛出Queue.Empty异常。如果blocked为False,有两种情况存在,如果Queue有一个值可用,则立即返回该值,否则,如果队列为空,则立即抛出Queue.Empty异常.

  • q.get_nowait():同q.get(False)
  • q.put_nowait():同q.put(False) q.empty():调用此方法时q为空则返回True,该结果不可靠,比如在返回True的过程中,如果队列中又加入了项目。
  • q.full():调用此方法时q已满则返回True,该结果不可靠,比如在返回True的过程中,如果队列中的项目被取走。
  • q.qsize():返回队列中目前项目的正确数量,结果也不可靠,理由同上.
import multiprocessing
import time


# 定义一个方法,向管道内发送消息
def putMsg(q):
    for i in ["A", "B", "C"]:
        print(f"向队列内放入{i}")
        # 插入数据到队列中
        q.put(i)
        time.sleep(2)


def getMsg(q):
    while True:
        # get()从队列读取并且删除一个元素;有两个可选参数:blocked和timeout。如果blocked为True(默认值),并且timeout为正值,那么在等待时间内没有取到任何元素,会抛出Queue.Empty异常。
        msg = q.get(True)
        print(f"从队列里获取{msg}")


if __name__ == "__main__":
    # 父进程创建队列,并传递给各个子进程
    queue = multiprocessing.Queue()
    putProcess = multiprocessing.Process(target=putMsg, args=(queue,))
    getProcess = multiprocessing.Process(target=getMsg, args=(queue,))
    putProcess.start()
    getProcess.start()
    # 等待“写入进程”结束
    putProcess.join()
    # “读取进程”是while死循环,不会自己结束只能强行终止
    getProcess.terminate()
    print("执行完成!")
'''
打印结果:

向队列内放入A
从队列里获取A
向队列内放入B
从队列里获取B
向队列内放入C
从队列里获取C

'''

注意:

1、queue.Queue是进程内(线程之间通信)非阻塞队列

2、multiprocess.Queue是跨进程通信队列。

线程之间通信示例:

# 生产者
import multiprocessing
import threading
import time
import queue

count = 1
q = queue.Queue(maxsize=10)


def producer():
    global count
    while True:
        print(f"生产了第{count}个鸡蛋")
        q.put(count)
        count += 1
        time.sleep(1)


# 消费者
def consumer():
    while True:
        print(f"消费了第 {q.get(timeout=10)}个鸡蛋")


if __name__ == '__main__':
    producterThread = threading.Thread(target=producer)
    consumerThread = threading.Thread(target=consumer)

    producterThread.start()
    consumerThread.start()
'''
生产了第1个鸡蛋
消费了第 1个鸡蛋
生产了第2个鸡蛋
消费了第 2个鸡蛋
生产了第3个鸡蛋
消费了第 3个鸡蛋
生产了第4个鸡蛋
消费了第 4个鸡蛋
生产了第5个鸡蛋
消费了第 5个鸡蛋
'''

Pipe

Pipe方法返回(conn1, conn2)代表一个管道的两个端。Pipe方法有duplex参数:duplex 为 True(默认值),那么这个管道是全双工模式,也就是说conn1和conn2均可收发。duplex 为 False,conn1只负责接受消息,conn2只负责发送消息。

send和recv方法分别是发送和接收消息的方法。在全双工模式下,可以调用conn1.send发送消息,conn1.recv接收消息。如果没有消息可接收,recv方法会一直阻塞。如果管道已经被关闭,那么recv方法会抛出EOFError;

import multiprocessing
import time


# 定义一个方法,向管道内发送消息
def putMsg(p):
    for i in ["A", "B", "C"]:
        print(f"向管道内放入{i}")
        # 插入数据到队列中
        p[1].send(i)
        time.sleep(2)


def getMsg(p):
    while True:
        # get()从队列读取并且删除一个元素;有两个可选参数:blocked和timeout。如果blocked为True(默认值),并且timeout为正值,那么在等待时间内没有取到任何元素,会抛出Queue.Empty异常。
        msg = p[0].recv()
        print(f"从管道里获取{msg}")


if __name__ == "__main__":
    # 父进程创建管道,并传递给各个子进程
    pipe = multiprocessing.Pipe(duplex=False)  # duplex 为 True(默认值),那么这个管道是全双工模式,也就是说conn1和conn2均可收发。duplex 为 False,conn1只负责接受消息,conn2只负责发送消息。pipe[0]:收消息;pipe[1]:发消息
    putProcess = multiprocessing.Process(target=putMsg, args=(pipe,))
    getProcess = multiprocessing.Process(target=getMsg, args=(pipe,))
    putProcess.start()
    getProcess.start()
    # 等待“写入进程”结束
    putProcess.join()
    # “读取进程”是while死循环,不会自己结束只能强行终止
    getProcess.terminate()
    print("执行完成!")

 

线程

进程是分配资源的最小单位,一旦创建一个进程就会分配一定的资源,就像跟两个人聊QQ就需要打开两个QQ软件一样是比较浪费资源的。

线程是程序执行的最小单位,实际上进程只负责分配资源,而利用这些资源执行程序的是线程,也就说进程是线程的容器一个进程中最少有一个线程来负责执行程序,同时线程自己不拥有系统资源,只需要一点儿在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源.这就像通过一个QQ软件(一个进程)打开两个窗口(两个线程)跟两个人聊天一样,实现多任务的同时也节省了资源。

每个线程都有他自己的一组CPU寄存器,称为线程的上下文,该上下文反映了线程上次运行该线程的CPU寄存器的状态。

指令指针和堆栈指针寄存器是线程上下文中两个最重要的寄存器,线程总是在进程得到上下文中运行的,这些地址都用于标志拥有线程的进程地址空间中的内存。

  • 线程可以被抢占(中断)。
  • 在其他线程正在运行时,线程可以暂时搁置(也称为睡眠) -- 这就是线程的退让。

线程可以分为:

  • 内核线程:由操作系统内核创建和撤销。
  • 用户线程:不需要内核支持而在用户程序中实现的线程。

Python3 线程中常用的两个模块为:

  • _thread
  • threading(推荐使用)

thread 模块已被废弃。用户可以使用 threading 模块代替。所以,在 Python3 中不能再使用"thread" 模块。为了兼容性,Python3 将 thread 重命名为 "_thread"。

多线程作用

图中是一个非常简单的程序,一旦运行hello.py这个程序,按照代码的执行顺序,func a函数执行完毕后才能执行func b函数.

假设func a函数的执行逻辑里面有需要等待IO操作或者网络加载等情况,那么单线程的情况下,func b必须等待a,导致CPU资源空闲。

如果想让func a和func b同时运行,除了多进程(需要操作系统分配资源)方法之外,还有多线程(不需要分配资源);

Python线程模块

Python3 通过两个标准库 _thread 和 threading 提供对线程的支持。

_thread 提供了低级别的、原始的线程以及一个简单的锁,它相比于 threading 模块的功能还是比较有限的。

threading 模块除了包含 _thread 模块中的所有方法外,还提供的其他方法:

  • threading.current_thread(): 返回当前的线程变量。
  • threading.enumerate(): 返回一个包含正在运行的线程的列表。正在运行指线程启动后、结束前,不包括启动前和终止后的线程。
  • threading.active_count(): 返回正在运行的线程数量,与 len(threading.enumerate()) 有相同的结果。
  • threading.Thread(target, args=(), kwargs={}, daemon=None)
    • 创建Thread类的实例。
    • target:线程将要执行的目标函数。
    • args:目标函数的参数,以元组形式传递。
    • kwargs:目标函数的关键字参数,以字典形式传递。
    • daemon:指定线程是否为守护线程。

threading.Thread 类提供了以下方法与属性:

    1. __init__(self, group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None)

      • 初始化Thread对象。
      • group:线程组,暂时未使用,保留为将来的扩展。
      • target:线程将要执行的目标函数。
      • name:线程的名称。
      • args:目标函数的参数,以元组形式传递。
      • kwargs:目标函数的关键字参数,以字典形式传递。
      • daemon:指定线程是否为守护线程。
    2. start(self)

      • 启动线程。将调用线程的run()方法。
    3. run(self)

      • 线程在此方法中定义要执行的代码。
    4. join(self, timeout=None)

      • 等待线程终止。默认情况下,join()会一直阻塞,直到被调用线程终止。如果指定了timeout参数,则最多等待timeout秒。
      • 如在一个线程B中调用thread1.join(),则thread1结束后,线程B才会接着thread1.join()往后运行。 
    5. is_alive(self)

      • 返回线程是否在运行。如果线程已经启动且尚未终止,则返回True,否则返回False
    6. getName(self)

      • 返回线程的名称。
    7. setName(self, name)

      • 设置线程的名称。
    8. ident属性:

      • 线程的唯一标识符。
    9. daemon属性:

      • 线程的守护标志,用于指示是否是守护线程。
    10. isDaemon()方法:

 线程创建步骤

有两种方式来创建线程:一种是通过继承Thread类,重写它的run方法:

#!/usr/bin/python3

import threading
import time

exitFlag = 0

class myThread (threading.Thread):
    def __init__(self, threadID, name, delay):
        threading.Thread.__init__(self)
        self.threadID = threadID
        self.name = name
        self.delay = delay
    def run(self):
        print ("开始线程:" + self.name)
        print_time(self.name, self.delay, 5)
        print ("退出线程:" + self.name)

def print_time(threadName, delay, counter):
    while counter:
        if exitFlag:
            threadName.exit()
        time.sleep(delay)
        print ("%s: %s" % (threadName, time.ctime(time.time())))
        counter -= 1

# 创建新线程
thread1 = myThread(1, "Thread-1", 1)
thread2 = myThread(2, "Thread-2", 2)

# 开启新线程
thread1.start()
thread2.start()
thread1.join()
thread2.join()
print ("退出主线程")

另一种是创建一个threading.Thread对象,在它的初始化函数(__init__)中将可调用对象作为参数传入。


# 1.导入线程模块
import threading
import time

def sing(num, songName):
    for i in range(num):
        print(f"演唱{songName}第{i + 1}次")
        time.sleep(1)


def dance(num):
    for i in range(num):
        print(f"跳舞...第{i + 1}次")
        time.sleep(1)


# 2.创建线程对象
if __name__ == '__main__':
    singThread = threading.Thread(target=sing, args=(3, "难忘今宵"))
    danceThread = threading.Thread(target=dance, args=(3,))

# 3.启动线程
    singThread.start()
    danceThread.start()
'''
线程参数说明:
    初始化源码:
        def __init__(self, group=None, target=None, name=None,args=(), kwargs=None, *, daemon=None)
    
    参数说明:
        group:目前只能为None
        target:要执行的函数名(方法名)
        name:进程名,一般不用设置,默认是Thread   -n
        args:函数的参数,注意为元组类型("a","b")
        kwargs:字典类型的参数
        daemon:是否守护线程
'''

守护主线程

主线程默认会等待所有子线程执行完之后才会关闭,但是如果我们希望主线程执行完毕之后(主线程关闭之后),子线程也同时销毁,也就是不想让主线程等待子线程,就可以通过守护线程实现;

什么是子线程? 包含在 threading.Thread中,里面均视为子线程。

什么是主线程? 除了“不包含在Thread里面的程序”,UI界面和Main函数均为主线程,均可视为主线程。

import threading
import time
def work():
    for i in range(10):
        print(f"工作中:{i+1}")
        time.sleep(0.2)
#主线程
if __name__ == '__main__':
    thread = threading.Thread(target=work,daemon=True)
    # thread.daemon=True
    thread.start()
    time.sleep(1)
    print("主线程关闭了...")

'''
设置守护线程之前:
工作中:1
工作中:2
工作中:3
工作中:4
工作中:5
主线程关闭了...
工作中:6
工作中:7
工作中:8
工作中:9
工作中:10

设置守护线程之后
工作中:1
工作中:2
工作中:3
工作中:4
工作中:5
主线程关闭了...
'''

 

线程执行顺序是无序的

import threading
import time

def task():
    time.sleep(1)
    # 获取当前线程对象
    thread = threading.current_thread()
    print(thread)

if __name__ == '__main__':
    for i in range(5):
        thread = threading.Thread(target=task)
        thread.start()
'''
 多线程之间,执行是无序的,由CPU调度。
 <Thread(Thread-5 (task), started 18632)><Thread(Thread-3 (task), started 23868)>
<Thread(Thread-4 (task), started 17720)>

<Thread(Thread-2 (task), started 15176)><Thread(Thread-1 (task), started 5280)>
 
'''

多线程实现文件拷贝

import os
import shutil
import threading
import time

def copyFile(file, sourceDirPath, destDirPath):
    try:
        shutil.copy(os.path.join(sourceDirPath, file), os.path.join(destDirPath, file))
        print(f"正在拷贝{file}")
    except Exception as e:
        print(f"{file}拷贝异常", e)

if __name__ == '__main__':
    t1 = time.time()
    print("开始时间:", t1)

    # 定义原文件目录路径和目标路径
    sourceDir = r"D:\learn\AI产品经理\8、AI大模型及算法解析"
    destDir = r"C:\Users\luzhanshi\Desktop\fileCopyTest"
    # 判断目标目录是否存在,不存在就创建
    if not os.path.exists(destDir):
        try:
            os.makedirs(destDir)
        except FileExistsError as e:
            print(f"创建文件夹出错!{e}")
    else:
        print("目标文件夹已存在,无须创建!")
    # 获取原文件夹所有文件列表
    listdir = os.listdir(sourceDir)
    threads = []
    for file in listdir:
        thread = threading.Thread(target=copyFile, args=(file, sourceDir, destDir))
        thread.start()
        threads.append(thread)
    for thread in threads:
        thread.join()
    print("全部拷贝完毕!")
    t2 = time.time()
    print("结束时间:", t2)
    print("总耗时时间:", t2 - t1)  # 总耗时时间: 0.6330888271331787

线程同步

如果多个线程共同对某个数据修改,则可能出现不可预料的结果,为了保证数据的正确性,需要对多个线程进行同步。

未加锁代码:理论上最后的打印结果不是0(但是我试了很多次,打印都是0,可能是我电脑性能好,当然,下面我会有详细解释)

import threading

# 定义银行存款全局变量
money = 0

# 定义修改银行存款的方法
def changeMoney(n):
    global money
    # 每次都是存入n元和取出n元,最后存款应该还是0元
    money += n
    money -= n

def changeManyTimes(n):
    for i in range(10000):
        changeMoney(n)

if __name__ == '__main__':
    # 单线程情况下,程序一行行执行,不存在争夺“money"的情况,所以最终结果还是0
    # changeManyTimes(1)
    # print(money)#0
    # 多线程情况下,存在抢占修改数据“money"的情况,由于多个线程在修改“money”时,在等待状态下,money被修改之后的值还没有被真正保存,而是暂存在寄存器当中,所以就会导致money加了n,但没来得及保存,或者减了n没来得及保存
    # 理论上使用多线程,最后打印的money不等于0,但是我反复实验多次,一直打印0;DeepSeek解释:用户的代码没有加锁,但由于每个线程的每次循环的净变化为零,所以无论线程如何交替执行,最终结果都是0。因此,多次运行结果都是0。这可能让用户误以为没有竞争条件,但实际上竞争条件存在,只是没有影响最终结果。这种情况下,代码的正确性是偶然的,而不是设计上的保证。
    thread1 = threading.Thread(target=changeManyTimes, args=(1,))
    thread2 = threading.Thread(target=changeManyTimes, args=(8,))
    thread1.start()
    thread2.start()
    thread1.join()
    thread2.join()
    print(money)

解释上述未加锁代码为何打印结果也是“0”:

你的代码在多线程环境下未加锁,但最终结果始终为 0,这看似矛盾,实则与 Python的GIL(Global Interpreter Lock,全局解释器锁)机制 和 操作原子性 密切相关。以下是详细分析:


1. 现象与直觉的矛盾

  • 直觉
    两个线程并发执行 money +=n 和 money -=n,若发生线程切换导致中间状态被覆盖,理论上 money 的最终值可能不为 0

  • 实际结果
    多次运行结果始终为 0,看似未发生竞争。


2. 核心原因:GIL与操作原子性

(1)GIL的调度机制

  • 单核CPU
    线程切换由GIL控制,同一时间仅一个线程执行字节码。money +=n 和 money -=n 在单次循环中连续执行,切换概率极低,导致中间状态不易暴露。

  • 多核CPU
    线程可能在不同核心并行执行,但GIL强制 串行化访问共享资源,实际效果类似单核。

(2)操作的“伪原子性”

GIL确保线程切换发生在字节码边界,但某些操作(如列表更新、字典修改、变量自增等)由多个字节码指令组成。例如:

    • money +=n 和 money -=n 的字节码

      # money +=n 的实际步骤:
      LOAD_GLOBAL money  # 读取当前值
      LOAD_CONST n       # 加载常数n
      INPLACE_ADD        # 执行加法
      STORE_GLOBAL money # 存储新值
      
      # money -=n 同理

如果线程在中间步骤(如刚执行完INPLACE_ADD但未STORE_GLOBAL)时释放GIL,其他线程可能读取到中间状态,导致数据不一致。

    • 关键点
      每个 += 或 -= 操作由 多个字节码指令组成,但 单次循环内 +=n 和 -=n 连续执行,线程切换后可能恰好完成一次完整的 +=n; -=n 操作,导致中间状态被“抵消”。

3. 结果始终为0的深层解释

(1)代码逻辑的“自洽性”

每次循环的净操作是:

money +=n → money -=n → money 不变
  • 若线程切换发生在 单次循环完成后,则无论执行顺序如何,最终结果仍为 0

  • 若线程切换发生在 单次循环内部(如 +=n 后、-=n 前),可能破坏中间状态,但概率极低。

(2)GIL的“偏向性”

  • Python 3.2+ 的GIL优化
    引入 sys.setswitchinterval 控制线程切换间隔(默认约 5ms)。在极高频操作中,线程可能在 完成大量循环后 才释放GIL,导致竞争概率降低。

(3)测试环境的局限性

  • 循环次数与CPU速度
    若CPU能在GIL时间片内完成所有 100000000 次循环,则线程实际 串行执行,无竞争。


4. 验证竞争的存在

(1)强制增加竞争概率

在 money +=n 和 money -=n 之间插入耗时操作(如虚拟I/O),放大竞争窗口:

def changeMoney(n):
    global money
    money +=n
    time.sleep(0.000001)  # 强制释放GIL,触发线程切换
    money -=n
  • 结果:多次运行后,money 会出现非 0 值(如 2-4 等)。

(2)减少循环次数

降低循环次数至 10000,使线程切换更频繁:

def changeManyTimes(n):
    for i in range(10000):  # 原为 100000000
        changeMoney(n)
  • 结果:可能出现非 0 值。


5. 结论

  • 代码结果正确是“巧合”
    GIL调度和操作连续性导致竞争未暴露,但 线程安全性未得到保证

  • 线程安全的正确做法
    对共享资源(如 money)使用锁(threading.Lock)或原子操作(如 atomic 模块)。

修正代码(加锁版)

加锁代码:

lock = threading.Lock()
# 加锁方法1def changeMoney(n):
    global money
    # 获取锁,用于线程同步
    with lock:
        money += n
        money -= n

# 加锁方式2:使用 Thread 对象的 Lock 和 Rlock 可以实现简单的线程同步,这两个对象都有 acquire 方法和 release 方法,对于那些需要每次只允许一个线程操作的数据,可以将其操作放到 acquire 和 release 方法之间。如下:
def changeMoney(n):
    global money
    # 获取锁,用于线程同步
    lock.acquire()
    money += n
    money -= n
    # 释放锁,开启下一个线程
    lock.release()

总结

你的代码因GIL调度和操作连续性“看似”线程安全,但实际存在竞争风险。永远不要依赖GIL实现线程安全,显式同步(如加锁)才是可靠方案。

关于锁的深入认识

为了更有效的利用多核处理器的性能,就出现了多线程的编程方式,而随之带来的就是线程间数据一致性和状态同步的困难。为了有效解决多份缓存之间的数据同步时各厂商花费了不少心思,就有了GIL,也不可避免的带来了一定的性能损失。

Python当然也逃不开,为了利用多核,Python开始支持多线程。而解决多线程之间数据完整性和状态同步的最简单方法自然就是加锁。 于是有了GIL这把超级大锁;

GIL

GIL(Global Interpreter Lock,全局解释器锁) 是 Python 中 CPython 解释器官方默认解释器)特有的机制,它是一种全局互斥锁,用于确保同一时刻只有一个线程可以执行 Python 字节码。以下是关于 GIL 的详细解析:


1. GIL 的核心作用
  • 线程安全
    简化 CPython 解释器的内存管理(如引用计数机制),避免多线程同时修改对象时出现数据竞争(Data Race)。

  • 性能优化
    在单线程场景下,无需频繁加锁,减少锁操作的开销。


2. GIL 的工作原理
    • 互斥执行
      当线程执行 Python 字节码时,必须先获取 GIL;执行完成后(或遇到 I/O 操作时),会释放 GIL。

    • 线程切换
      解释器通过固定规则(如时间片或字节码计数)强制释放 GIL,允许其他线程竞争锁并执行。

3. GIL 的优缺点

优点

  • 简化内存管理
    无需为每个对象单独加锁,降低内存管理复杂度(如引用计数的原子性操作)。

  • 单线程性能优化
    避免多线程竞争导致的锁开销,单线程执行效率更高。

缺点

  • 限制多核并行
    在 CPU 密集型任务中,多线程无法利用多核 CPU 的并行能力(同一时刻仅一个线程运行)。

  • 性能瓶颈
    多线程竞争 GIL 时,频繁的锁获取/释放会导致额外开销,甚至性能劣化。


4. GIL 的典型影响场景

CPU 密集型任务

  • 现象
    多线程执行计算任务时,效率反而低于单线程(如数值计算、图像处理)。

  • 原因
    GIL 强制线程串行执行,无法利用多核并行加速。

I/O 密集型任务

  • 现象
    多线程仍可提升效率(如网络请求、文件读写)。

  • 原因
    线程在等待 I/O 时会释放 GIL,允许其他线程执行。


5. 如何规避 GIL 的限制

(1) 使用多进程代替多线程

    • 原理
      每个进程拥有独立的 GIL,可并行利用多核 CPU。

    • 实现

      使用 multiprocessing 模块:

from multiprocessing import Pool

def cpu_intensive_task(x):
    return x * x

if __name__ == "__main__":
    with Pool(4) as p:
        print(p.map(cpu_intensive_task, [1, 2, 3, 4]))

用multiprocessing替代Thread

multiprocessing库的出现很大程度上是为了弥补thread库因为GIL低效的缺陷。

它完整的复制了一套thread所提供的接口方便迁移。唯一的不同就是它使用了多进程而不是多线程。每个进程有自己的独立的GIL,完全并行,无GIL的限制(进程中包括线程),可充分利用多cpu多核的环境,因此也不会出现进程之间的GIL争抢。如果想要充分地使用多核CPU的资源,在python中大部分情况需要使用多进程。

multiprocessing包是Python中的多进程管理包。与threading.Thread类似,它可以利用multiprocessing.Process对象来创建一个进程。该Process对象与Thread对象的用法相同,也有start(), run(), join()等方法。

此外multiprocessing包中也有Lock/Event/Semaphore/Condition类 (这些对象可以像多线程那样,通过参数传递给各个进程),用以同步进程,其用法与threading包中的Thread类一致。所以,multiprocessing的很大一部份与threading使用同一套API,只不过换到了多进程的情境。

 

 

(2) 使用 C 扩展或无 GIL 的解释器

  • C 扩展
    在 C 代码中释放 GIL(如通过 Py_BEGIN_ALLOW_THREADS 和 Py_END_ALLOW_THREADS 宏)。

  • 其他解释器
    使用 Jython(基于 JVM)或 IronPython(基于 .NET),它们没有 GIL。

(3) 异步编程(asyncio)

    • 适用场景
      I/O 密集型任务,通过协程实现高并发,避免线程切换开销。

import asyncio

async def fetch_data(url):
    await asyncio.sleep(1)  # 模拟 I/O 操作
    return f"Data from {url}"

async def main():
    tasks = [fetch_data(f"url_{i}") for i in range(3)]
    results = await asyncio.gather(*tasks)
    print(results)

asyncio.run(main())

6. 常见误解澄清

误解 1:GIL 是 Python 语言的特性

  • 真相
    GIL 是 CPython 解释器 的实现细节,并非 Python 语言规范。其他解释器(如 Jython)没有 GIL。

误解 2:GIL 导致 Python 无法多线程编程

  • 真相
    GIL 仅限制 CPU 密集型任务 的并行性,I/O 密集型任务仍可受益于多线程。

误解 3:移除 GIL 会大幅提升性能

  • 真相
    移除 GIL 会导致单线程性能下降(因需频繁加锁),且内存管理复杂度激增。目前 CPython 社区正在探索无 GIL 模式(如 PEP 703),但进展缓慢。


7. 总结

    • GIL 的本质
      CPython 解释器为简化实现引入的全局锁,确保线程安全,但牺牲了多核并行能力。

    • 适用场景

      • CPU 密集型任务 → 多程/C 扩展

      • I/O 密集型任务 → 多线程/异步编程

    • 未来展望
      无 GIL 的 Python 解释器仍处于实验阶段,短期内 CPython 的 GIL 机制不会消失。

GIL无疑就是一把全局排他锁。毫无疑问全局锁的存在会对多线程的效率有不小影响。甚至就几乎等于Python是个单线程的程序。 那么读者就会说了,全局锁只要释放的勤快效率也不会差啊。只要在进行耗时的IO操作的时候,能释放GIL,这样也还是可以提升运行效率的嘛。或者说再差也不会比单线程的效率差吧。理论上是这样,而实际上呢?Python比你想的更糟。

但当CPU有多个核心的时候,问题就来了。从release GIL到acquire GIL之间几乎是没有间隙的。所以当其他在其他核心上的线程被唤醒时,大部分情况下主线程已经又再一次获取到GIL了。这个时候被唤醒执行的线程只能白白的浪费CPU时间,看着另一个线程拿着GIL欢快的执行着。然后达到切换时间后进入待调度状态,再被唤醒,再等待,以此往复恶性循环。GIL的存在导致多线程无法很好的利用多核CPU的并发处理能力。

GIL并不是Python的特性,Python完全可以不依赖于GIL 

 

线程和进程的对比

关系对比

1.线程是依附在进程里面的,没有进程就没有线程
2.一个进程默认提供一条线程,进程可以创建多个线程。

 区别对比

1.创建进程的资源开销要比创建线程的资源开销要大
2.进程是操作系统资源分配的基本单位,线程是CPU调度的基本单位

3.线程不能够独立执行,必须依存在进程中

优缺点对比

  • 进程:启动时分配独立的内存、文件句柄等资源,资源隔离性强,但创建和切换成本高。

  • 线程:共享同一进程的内存和资源,创建和切换成本极低(仅需分配栈和寄存器)。

多线程的代价与注意事项

  • 线程切换开销:频繁切换线程可能浪费 CPU 周期(需权衡线程数量)。

  • 同步问题:共享资源需加锁(如互斥锁),不当使用会导致死锁或性能下降。

  • 适用场景

    • 适合:I/O 密集型、可并行计算的任务。

    • 不适合:单核纯 CPU 密集型任务(可能更慢)。

 Pipe和Queue

 

posted @ 2025-03-17 11:00  指尖下的世界  阅读(37)  评论(0)    收藏  举报