python中的并发编程-进程、线程

前言

并发编程是一种编程范式,它允许程序在执行时可以同时处理多个任务。这种编程方式的核心思想是利用系统资源,如CPU核心,以提高程序的执行效率和响应速度。并发编程在多核处理器和分布式系统上尤为重要,因为它们可以充分利用硬件资源来执行多个任务。

线程与进程:并发编程通常涉及到线程和进程的概念。线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位。一个进程中可以同时运行多个线程,这些线程共享进程的资源。

下面我们就进入并发编程的相关概念学习

进程

进程是计算机中的程序关于数据集合上的一次运行活动,它是操作系统进行资源分配和调度的基本单位。进程是程序在执行过程中的一个动态实体,它包含了程序执行的状态和程序在执行过程中所需的资源。

进程与程序的区别

  1. 程序(Program)

    • 程序是指令和数据的集合,它是静态的、被动存在于磁盘或其他存储设备中。
    • 程序本身不占用系统资源,除了所占据的存储空间。
    • 程序不能独立执行,需要通过加载到内存并由CPU执行变成进程。
  2. 进程(Process)

    • 进程则是程序在处理器上运行时的动态表现形式。
    • 它包括正在执行的指令、程序计数器、寄存器内容以及变量等运行时信息。
    • 进程具有生命周期,并且在其生命周期内会消耗CPU时间和其他系统资源。

进程的组成

一个典型进程由以下几个部分组成:

  1. 文本段(Text Segment):也称为代码段,包含了要执行的编译后机器语言指令。

  2. 数据段(Data Segment):包含全局变量和静态变量等,在程序运行期间可能会被修改。

  3. 堆(Heap):动态分配内存时使用,如C语言中使用malloc或C++中使用new分配得到。

  4. 栈(Stack):用于函数调用时保存返回地址、局部变量、参数等信息。栈具有自增长和自缩小功能,并且通常位于高地址向低地址增长。

  5. PCB (Process Control Block):每个进程都有一个PCB来保存该进程相关信息。这些信息包括:

    • 进程标识符 (PID)
    • 进度状态
    • 程序计数器
    • CPU寄存器
    • CPU调度信息
    • 内存管理信息
    • 账号和审计信息
    • I/O状态信息

进程标记

  • PID (Process Identifier):每个进城都有唯一标识符称为PID。这个ID用来跟踪每个活动过或正在活动过得所有进城。
  • 在某些情况下还可以使用父子关系标记来表示多个相关联但又相互独立运行得多个任务。
import os
import time

if __name__ == '__main__':
    for i in range(60):
        time.sleep(1)  # 当前线程阻塞1秒
        # os.getpid()  # 获取当前进程的PID
        # os.getppid()  # 获取当前进程的父级(parent process)进程的PID
        print(f"第 {i} 次:当前进程的PID:{os.getpid()},父进程的PID:{os.getppid()}")

"""
在当前程序中,基于pycharm来运行代码的时候,实际的运行流程:
1. pycharm调用了python解释器,实际上pycharm通过创建进程的方式来调用了python解释器的。
2. python解释器执行了python模块代码

在当前程序中,基于cmd命令来运行代码的时候,实际上的运行流程:
1. cmd终端调用了python解释器,实际上cmd终端通过创建进程的方式来调用了python解释器的。
2. python解释器执行了python模块代码
"""

进程的调度

进程调度是操作系统中一个非常重要的功能,它负责决定何时以及如何在多个进程之间分配CPU时间。进程调度的目标是确保系统资源的有效利用,提高系统性能,以及保证各个进程公平、合理地共享处理器资源。

  1. 调度队列:操作系统维护一个或多个队列,称为就绪队列,其中包含了所有准备好执行但尚未被分配到处理器的进程。
  2. 调度算法:操作系统使用特定的算法来选择下一个将要执行的进程。这些算法根据进程的优先级、执行时间、资源需求等因素来做出决策。
  3. 上下文切换:当CPU从一个进程切换到另一个进程时,操作系统需要保存当前进程的状态(上下文),并恢复下一个被调度进程的状态。这个过程是通过上面说的PCB来保存的。

操作系统通过进程控制块(PCB)来跟踪进程的状态和信息。当调度器选择一个进程执行时,它会加载该进程的上下文到CPU,并开始执行。一旦进程完成、被阻塞或时间片用完,调度器会进行上下文切换,选择另一个进程继续执行。

调度的考虑因素

  • 响应时间:系统对用户请求的响应速度。
  • 吞吐量:单位时间内系统完成的工作量。
  • 资源利用率:CPU、内存等资源的使用效率。
  • 公平性:确保所有进程都能公平地获得处理器时间。

常见的调度算法

1. 先来先服务(FCFS,First-Come First-Served)

先来先服务是最简单和最直观的调度算法。在这个算法中,进程按照它们到达就绪队列的顺序被执行。第一个到达的进程首先被分配CPU,然后是第二个,以此类推。尽管这种算法实现简单且公平,但它可能导致长作业阻塞短作业,即后来的短作业必须等待长的作业完成才能执行。

2. 短作业优先(SJF,Shortest Job First)

短作业优先算法,也称为最短剩余时间优先(SRTF),旨在最小化所有进程的平均等待时间。在这个算法中,每次选择预计执行时间最短的进程来执行。这种方法可以减少等待时间,但可能导致长作业饿死,即长作业可能永远得不到执行,因为总有短作业不断地到来并优先执行。

3. 优先级调度

优先级调度算法为每个进程分配一个优先级,根据优先级来决定哪个进程应该被执行。优先级可以是静态的(在进程创建时确定)或动态的(根据进程的行为和需求在运行时调整)。调度器总是选择优先级最高的进程来执行。这种算法可能导致低优先级进程饿死,特别是当高优先级进程不断到来时。

4. 时间片轮转(RR,Round Robin)

时间片轮转算法是一种抢占式调度算法,它将所有就绪进程按到达顺序排列,并为每个进程分配一个固定长度的时间片。每个进程在其时间片内执行,如果时间片用完而进程未完成,它将被放回就绪队列末尾等待下一轮调度。这种算法确保了所有进程都能公平地获得CPU时间,但可能导致频繁的上下文切换,增加了系统开销。

在轮转法中,加入到就绪队列的进程有3种情况:

  1. 分给它的时间片用完,但进程还未完成,回到就绪队列的末尾等待下次调度去继续执行。
  2. 分给该进程的时间片并未用完,只是因为请求I/O或由于进程的互斥与同步关系而被阻塞。当阻塞解除之后再回到就绪队列。
  3. 新创建进程进入就绪队列。
5. 多级反馈队列(MFQ,Multilevel Feedback Queue)

多级反馈队列算法结合了时间片轮转和优先级调度的特点。它将进程分配到多个具有不同时间片长度的队列中。每个队列可以有不同的优先级,通常优先级越高的队列时间片越短。进程可以根据其行为(如I/O等待时间、已用CPU时间)在队列之间移动。这种算法旨在平衡响应时间和吞吐量,同时避免饿死现象。

属于上面几种算法的折中算法,是目前被公认的最优,最公平的进程/作业调度算法,它不必事先知道各种进程的执行时间,也满足各种类型进程的调度需要。

6. 最高响应比优先(HRN,Highest Response Ratio Next)

最高响应比优先算法根据进程的等待时间和服务时间计算响应比,并选择响应比最高的进程来执行。响应比 = (等待时间 + 服务时间) / 服务时间。这种算法试图平衡短作业的快速响应和长作业的合理等待时间。

7. 公平共享调度(Fair Share Scheduling)

公平共享调度算法旨在确保每个进程或进程组都能获得公平的处理器时间。它可以根据进程的重要性、资源需求或其他策略来分配时间份额。这种算法适用于多任务环境,确保没有单个进程或进程组独占CPU。

进程的状态

  1. 新建(New):进程刚被创建,但还未被调度运行。
  2. 就绪(Ready):进程已准备好运行,等待CPU分配时间片进行执行。
  3. 运行(Running):进程正在CPU上执行。
  4. 阻塞(Blocked)或等待(Waiting):进程因等待某个事件发生而暂停执行。例如,等待输入/输出操作完成、等待文件系统的访问、网络数据的到来或其他资源的释放。
  5. 终止(Terminated)或退出(Exited):进程完成执行后退出。
import time

print("程序开始执行")  # 此时程序对应的进程处于“运行”状态

name = input("模拟等待用户输入:")  # 程序在这里会转入“阻塞”状态,因为它在等待用户输入

print(name)  # 用户完成输入后,程序再次回到“运行”状态

print("程序开始阻塞5秒")
time.sleep(5)  # 这里程序再次因调用sleep函数而转入“阻塞”状态
print("程序阻塞5秒结束")

print("程序结束")  # 最终当这段代码执行完毕后, 对应的进程会转入“终止”状态

  • input函数调用期间和time.sleep(5)期间,代码示例中对应的进程会处于"阻塞"状态。这是因为它们都涉及到了需要等待某些事件完成——一个是用户输入,另一个是时间延迟——在此期间CPU可以切换去做其他任务。

  • 当不在调用inputtime.sleep()函数时,在打印语句之间以及最开始打印"程序开始执行"时, 程序对应的进程则处于"运行"状态。

  • 当整个脚本执行完毕后, 对应的Python脚本所代表的进程将会结束, 即达到了"终止"态。

进程的创建

os.fork()

Unix系统下的os.fork()方法创建一个子进程。os.fork()是一个在类Unix操作系统中用于创建进程的系统调用。在Windows系统中,os.fork()并不可用。

import os

if __name__ == '__main__':
    # 通过fork创建一个子进程
    value = 100
    print("创建子进程前的打印,只会执行一次")

    # 创建子进程
    pid = os.fork()  # 在子进程中pid=0,而在父进程中会pid大于0,原因是因为父进程中执行了fork,所以得到子进程的pid作为返回值
    ppid = os.getppid()
    print(
        f"创建子进程后的打印,会执行两次,主进程一次,子进程一次:当前进程PID: {os.getpid()} ,value = {value} , pid = {pid} , ppid = {ppid}")
    if pid == 0:
        print(f'value={value}, 子进程PID={os.getpid()},当前子进程的父进程PID={os.getppid()}')
    else:
        print(f'当前进程PID:{os.getpid()},创建了一个子进程,PID={pid}')

  1. value = 100:在全局作用域中定义了一个变量value,并赋值为100。这个变量将在父进程和子进程中各自拥有一个副本。
  2. print("创建子进程前的打印,只会执行一次"):这行代码只会在父进程中执行一次。os.fork()调用之前的所有语句只会在父进程中执行。
  3. pid = os.fork():这行代码执行os.fork()系统调用,创建一个新的子进程。在父进程中,pid将是一个大于0的值(子进程的PID)。在子进程中,pid将为0。
  4. print( ... ):这个打印语句会在父进程和子进程中各执行一次。由于这个打印语句位于os.fork()之后,因此它的输出将在父进程和子进程中各出现一次。
  5. if pid == 0::这个条件判断当前代码块是否在子进程中执行。如果是(pid == 0),则执行子进程特有的代码块。
  6. else::如果pid不为0,则当前代码块在父进程中执行。

实际的输出可能会因为操作系统的调度和print函数的内部缓冲机制而有所不同。此外,由于os.fork()创建了一个新的进程,父进程和子进程的执行是并发的,它们的执行顺序是不确定的。

此外,子进程是父进程的一个副本,所以它继承了父进程的所有变量和文件描述符等资源。在这个例子中,全局变量value被两个进程共享,所以在子进程中打印的value的值将是100。

输出:

创建子进程前的打印,只会执行一次
创建子进程后的打印,会执行两次,主进程一次,子进程一次:当前进程PID: 87451 ,value = 100 , pid = 87452 , ppid = 63940
当前进程PID:87451,创建了一个子进程,PID=87452
创建子进程后的打印,会执行两次,主进程一次,子进程一次:当前进程PID: 87452 ,value = 100 , pid = 0 , ppid = 87451
value=100, 子进程PID=87452,当前子进程的父进程PID=87451

multiprocessing模块

multiprocessing模块是Python标准库中的一个功能强大的模块,允许程序员以并发的方式运行多个子进程,并提供了与线程相似的API。

import multiprocessing
import os
import time


def worker(num):
    """线程工作函数"""
    pid = os.getpid()
    ppid = os.getppid()
    print(f'Worker: {num} 启动,即将阻塞3s,当前pid: {pid} , 父pid: {ppid} ')
    time.sleep(3)


if __name__ == '__main__':
    pid = os.getpid()
    print(f" 主进程 ,pid:{pid}")

    # 创建进程
    p1 = multiprocessing.Process(target=worker, args=(1,))
    time.sleep(1)  # 只是阻塞其创建,真正执行是在start()方法
    p2 = multiprocessing.Process(target=worker, args=(2,))

    # 启动进程
    p1.start()
    p2.start()

    # 等待进程结束
    p1.join()
    p2.join()

    print("主进程结束")

  1. 导入必要的模块:multiprocessing, os, 和 time
  2. 定义了一个名为 worker 的函数,它接受一个参数 num。该函数打印出当前工作的子进程编号、PID(进程ID)和父进程的PID,然后调用 time.sleep(3) 使得当前执行该函数的线程阻塞3秒钟。
  3. 创建两个子进程对象 p1p2,它们都将执行上面定义好的 worker() 函数。对于子进程对象p1传入参数 (1,), 对于p2传入参数 (2,).
  4. 主程序休眠1秒钟(通过调用 time.sleep(1))。
  5. 启动两个子进程通过调用它们各自的 .start() 方法。这会导致每个子进程分别运行其目标函数 (worker)。
  6. 调用 .join() 方法等待每个启动过的子进程结束。.join() 方法会阻塞当前线(在本例中即主线)直到被其调用过 .join() 的那些线结束运行为止。
  7. 当所有子线都已经完成时,打印 “主线结束” 消息,并且程序终止。

具体执行顺序如下:

  • 首先输出 " 主线 ,pid:[pid值]"
  • 然后创建第一个工作线 p1,并在一秒后创建第二个工作线 p2。
  • 接着启动 p1 和 p2;几乎同时,两个工作线开始运行并输出各自包含 “Worker: [num]” 的消息。
  • 每个工作线都会阻塞3秒钟。
  • 主程序等待这两个工作线完成;因为它们是几乎同时开始运行并且都阻塞了相同时间长度(3秒),所以也将几乎同时结束。
  • 最后,在所有工作线完成之后,输出 “主程序结束” 并退出程序。

输出:

 主进程 ,pid:90655
Worker: 1 启动,即将阻塞3s,当前pid: 90661 , 父pid: 90655 
Worker: 2 启动,即将阻塞3s,当前pid: 90662 , 父pid: 90655 
主进程结束

注意!!!!:当你使用多处理时,请确保在 Windows 平台上将相关代码放置在由 if __name__ == '__main__': 守卫起来的区域内。这样做是因为Windows没有fork机制,必须通过序列化和反序列化对象状态来启动新处理器,在此过程中可能会无意间再次运行多余的代码。

通过继承Process进程类方式实现,效果与上面一样

import os
import time
from multiprocessing import Process


class MyWorker(Process):
    num = None

    def __init__(self, num):
        self.num = num
        super().__init__()

    def run(self):
        pid = os.getpid()
        ppid = os.getppid()
        print(f'Worker: {self.num} 启动,即将阻塞3s,当前pid: {pid} , 父pid: {ppid} ')
        time.sleep(3)


if __name__ == '__main__':
    pid = os.getpid()
    print(f" 主进程 ,pid:{pid}")

    # 创建进程
    p1 = MyWorker(1)
    p2 = MyWorker(2)

    # 启动进程
    p1.start()
    p2.start()

    # 等待进程结束
    p1.join()
    p2.join()

    print("主进程结束")

不足

  1. 资源开销:创建和销毁进程需要较多的资源和时间。操作系统必须为每个新进程分配独立的内存空间、文件描述符、安全上下文等资源,这些操作都涉及到复杂的系统调用和资源管理。

  2. 上下文切换开销:当操作系统在多个进程之间切换时,它需要保存当前进程的状态并加载另一个进程的状态。这个过程称为上下文切换,它涉及到大量的数据传输和内存操作,因此在多进程环境下,上下文切换的开销可能会很高。

  3. 通信复杂性:进程间通信(IPC)需要特定的机制,如管道、信号、消息队列、共享内存等。这些通信方式相对复杂,需要额外的编程工作和资源管理。此外,进程间通信还可能引入数据同步和锁定的问题。

  4. 数据共享困难:由于每个进程都有自己的独立内存空间,进程间共享数据变得困难。为了在进程间共享数据,必须使用特定的IPC机制,这增加了编程的复杂性和出错的风险。

  5. 性能开销:在某些情况下,进程模型可能导致性能开销。例如,每个进程都需要有自己的系统调用接口和资源管理机制,这可能导致额外的CPU和内存开销。

为了解决上述进程模型的一些不足,所以接下来我们需要学习线程

线程

定义

线程(Thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。它们在同一个进程内共享内存和资源,这使得线程之间的创建、销毁、通信和数据共享更加高效。线程的上下文切换通常比进程的上下文切换更快,因为线程共享大部分进程资源,如文件描述符、全局变量等,不需要切换完整的内存空间。

  1. 轻量级:线程的创建、销毁和切换的开销都比进程要小得多,因为线程共享所属进程的资源,不需要独立的地址空间。

  2. 并发性:在多线程的环境下,多个线程可以并发执行,提高了程序的执行效率和响应速度。

  3. 共享资源:同一进程内的线程可以访问进程的共享资源,如内存、文件句柄等,这使得线程间的通信和数据共享更为简便。

  4. 独立执行:每个线程拥有自己的执行路径,可以独立于其他线程执行。

  5. 调度:线程的调度通常由操作系统的线程调度器进行管理,调度器根据线程的优先级、调度策略等因素决定线程的执行顺序。

  6. 同步与互斥:由于线程共享进程资源,因此可能需要同步机制来防止数据竞争和确保数据一致性。线程同步可以通过锁、信号量、条件变量等同步原语来实现。

线程的这些特性使得它们非常适合于需要频繁交互和共享数据的任务,如用户界面事件处理、网络通信、并行计算等。通过多线程,程序可以更有效地利用多核处理器的计算能力,提高程序的性能和用户体验。然而,多线程编程也需要谨慎处理同步和并发问题,以避免死锁、竞态条件等多线程相关的陷阱。

与进程的区别

  1. 资源共享

    • 线程在同一个进程中运行,它们共享进程的内存空间和资源,这使得线程间的通信和数据共享更加容易和高效。相比之下,进程间通信(IPC)需要更复杂的机制,如管道、消息队列、共享内存等。
  2. 开销更小

    • 创建和销毁线程的开销远小于进程。线程的创建只需要为其分配少量的内核资源,如执行栈、程序计数器和一组寄存器,而进程则需要完整的内存空间和系统资源。
    • 线程的上下文切换比进程更快,因为线程共享相同的虚拟内存和系统资源。
  3. 提高应用性能

    • 在多核处理器上,多线程可以实现真正的并行计算,每个核心可以运行不同的线程,这可以显著提高应用程序的性能。
    • 对于IO密集型任务,线程可以在不影响其他线程的情况下进行阻塞操作,这样可以保持应用程序的响应性。
  4. 简化程序设计

    • 在某些情况下,使用线程可以简化程序的设计。例如,一个服务器可以为每个新的客户端连接创建一个新线程,这样每个线程可以独立处理一个客户端请求。
  5. 利用多核优势

    • 现代计算机通常都有多核处理器,使用线程可以更好地利用多核处理器的计算能力,因为线程可以被分配到不同的处理器核心上执行。
  6. 用户体验和响应性

    • 在图形用户界面(GUI)应用程序中,使用多线程可以避免长时间的计算或IO操作阻塞UI线程,从而提高应用程序的响应性。
  7. 适应性

    • 某些编程环境或库设计为多线程环境,使用线程可以更好地适应这些环境。

线程的组成

线程是由多个组成部分构成的,这些组成部分共同定义了线程的执行状态和行为。以下是线程的主要组成部分:

  1. 线程ID:每个线程都有一个唯一的标识符,称为线程ID(或线程标识符)。这个ID用于区分进程内的各个线程,并在操作系统和程序中进行引用。

  2. 程序计数器(Program Counter, PC):程序计数器存储了线程当前正在执行的指令的地址。它是线程执行状态的关键部分,确保每次线程切换回来时能够从正确的位置继续执行。

  3. 栈(Stack):线程拥有自己的栈,用于存储局部变量、方法调用的返回地址以及其他临时数据。栈是线程执行函数调用和恢复调用现场的关键结构。

  4. 寄存器集合:线程拥有一组寄存器,这些寄存器保存了当前执行的指令状态,包括通用寄存器、状态寄存器、程序计数器等。保存寄存器集合是上下文切换的关键步骤。

  5. 线程状态:线程状态指示线程的当前运行情况,如就绪(Ready)、运行(Running)、阻塞(Blocked)或死亡(Terminated)等。

  6. 线程优先级:线程优先级决定了线程在操作系统调度中的优先级。具有较高优先级的线程可能会比低优先级线程更频繁地被调度执行。

  7. 线程上下文:线程上下文是指保存线程执行状态的所有信息,包括程序计数器、寄存器集合、栈等。操作系统在线程切换时会保存和恢复这些上下文信息。

  8. 线程同步数据:为了实现线程间的同步,线程可能包含锁、信号量、条件变量等同步原语的数据结构。

  9. 线程属性:线程属性包括线程的创建属性(如堆栈大小、调度策略等)以及其他控制线程行为的选项。

  10. 关联的进程:线程总是属于某个进程,它与进程的其他线程共享进程的内存空间和资源。

组件同个进程下,线程间是否共享描述
线程ID每个线程都有唯一的标识符。
指令指针(Program Counter, PC)每个线程都有自己的程序计数器,表示下一条要执行的指令地址。
寄存器集合包含当前工作变量等信息,每个线程都有自己独立的寄存器集合。
堆栈用于存储局部变量、函数参数和返回地址等临时数据;每个线程都有自己独立的堆栈空间。
堆内存用于动态分配内存;所有线程可以访问相同进区域,并需要适当地进行同步以避免冲突。
全局变量所有线程可以访问全局变量,并且对其进行读写操作需要注意数据一致性问题。
静态变量在函数或类中定义为静态(static)或全局范围内有效;所有线程序可见并可能修改它们。
文件描述符打开文件、网络连接等资源由文件描述符管理;这些文件描述符在多个线中是共享使用的。
代码段进行代码执行所需机器码区域;所有工作在线均可执行相同代码段而无需复制到各自空间中去。
数据段/静态数据初始化过全局变量和静态数据保存在此区域内,在多工作在线之间被分享。

线程的创建

线程池

from concurrent.futures import ThreadPoolExecutor
import threading
import time


# 定义一个函数,这个函数将在线程池中运行
def print_numbers(name):
    # 获取当前线程对象
    current_thread_obj = threading.current_thread()

    for i in range(1, 3):
        # 打印当前线程对象的名称
        print(f"当前线程 name【{current_thread_obj.name}】 ,id【{current_thread_obj.ident}】,变量【{id(name)}】,等待第{i}秒")
        time.sleep(1)


# 创建一个线程池,参数指池中最大的线程数,超过这个数量的任务将排队等待
with ThreadPoolExecutor(max_workers=2) as executor:
    name = 9999999999
    print(f"主线程中打印变量的id地址: {id(name)}")
    # 将任务提交到线程池
    future1 = executor.submit(print_numbers, name)
    future2 = executor.submit(print_numbers, name)
    future3 = executor.submit(print_numbers, name)

    # 等待任务完成
    future1.result()
    future2.result()
    future3.result()

print("线程已全部完成。")
  1. def print_numbers(name):
    定义了一个名为print_numbers的函数,它接受一个参数name
    1. 通过调用threading.current_thread()获取当前执行的线程对象,并将其存储在变量current_thread_obj中。

    2. 在循环内部,打印当前线程的名称、ID、传入函数的变量name的内存地址(通过id()函数获取),以及当前等待的秒数。

  2. with ThreadPoolExecutor(max_workers=2) as executor:
    使用with语句创建一个线程池,max_workers=2表示这个线程池最多只能有2个线程同时运行。as executor将线程池赋值给变量executor
  3. 在提交任务到线程池之前,先打印出变量name的内存地址。
  4. future1 = executor.submit(print_numbers, name)
    将任务提交到线程池,executor.submit方法接受一个函数和它的参数,这里提交的是print_numbers函数和参数name。返回的是一个Future对象,代表了异步执行的操作。
  5. future1.result()future2.result()future3.result()
    依次等待三个任务完成,并获取它们的结果。

需要注意的是,由于线程池中最多只有两个线程同时运行,所以print_numbers函数的执行将会交替进行,可能会看到输出交错的情况。此外,由于name变量在三个任务中被共享,它们将打印出相同的内存地址。

输出结果:

主线程中打印变量的id地址: 4515414896
当前线程 name【ThreadPoolExecutor-0_0】 ,id【123145361174528】,变量【4515414896】,等待第1秒
当前线程 name【ThreadPoolExecutor-0_1】 ,id【123145377964032】,变量【4515414896】,等待第1秒
当前线程 name【ThreadPoolExecutor-0_0】 ,id【123145361174528】,变量【4515414896】,等待第2秒
当前线程 name【ThreadPoolExecutor-0_1】 ,id【123145377964032】,变量【4515414896】,等待第2秒
当前线程 name【ThreadPoolExecutor-0_0】 ,id【123145361174528】,变量【4515414896】,等待第1秒
当前线程 name【ThreadPoolExecutor-0_0】 ,id【123145361174528】,变量【4515414896】,等待第2秒
线程已全部完成。

Thread创建

import threading
import time
from threading import Thread


# 定义一个函数,这个函数将在线程池中运行
def print_numbers(name):
    # 获取当前线程对象
    current_thread_obj = threading.current_thread()

    for i in range(1, 3):
        # 打印当前线程对象的名称
        print(f"当前线程 name【{current_thread_obj.name}】 ,id【{current_thread_obj.ident}】,变量【{id(name)}】,等待第{i}秒")
        time.sleep(1)


name_out = 9999999999
print(f"主线程中打印变量的id地址: {id(name_out)}")
# 将任务提交到线程池
future1 = Thread(target=print_numbers, name='线程1号', args=(name_out,))
future2 = Thread(target=print_numbers, name='线程2号', args=(name_out,))
future3 = Thread(target=print_numbers, name='线程3号', args=(name_out,))

# 等待任务完成
future1.start()
future2.start()
future3.start()

future1.join()
future2.join()
future3.join()

print("线程已全部完成。")

输出:

主线程中打印变量的id地址: 4586648432
当前线程 name【线程1号】 ,id【123145411108864】,变量【4586648432】,等待第1秒
当前线程 name【线程2号】 ,id【123145427898368】,变量【4586648432】,等待第1秒
当前线程 name【线程3号】 ,id【123145444687872】,变量【4586648432】,等待第1秒
当前线程 name【线程1号】 ,id【123145411108864】,变量【4586648432】,等待第2秒
当前线程 name【线程3号】 ,id【123145444687872】,变量【4586648432】,等待第2秒
当前线程 name【线程2号】 ,id【123145427898368】,变量【4586648432】,等待第2秒
线程已全部完成。

继承Thread类

import threading
import time
from threading import Thread


class MyThread(Thread):
    name_out = None

    def __init__(self, name_out):
        self.name_out = name_out
        super().__init__()

    def run(self):
        # 获取当前线程对象
        current_thread_obj = threading.current_thread()

        for i in range(1, 3):
            # 打印当前线程对象的名称
            print(
                f"当前线程 name【{current_thread_obj.name}】 ,id【{current_thread_obj.ident}】,变量【{id(self.name_out)}】,等待第{i}秒")
            time.sleep(1)


name = 9999999999
print(f"主线程中打印变量的id地址: {id(name)}")
# 将任务提交到线程池
future1 = MyThread(name)
future2 = MyThread(name)
future3 = MyThread(name)

# 等待任务完成
future1.start()
future2.start()
future3.start()

future1.join()
future2.join()
future3.join()

print("线程已全部完成。")

输出:

主线程中打印变量的id地址: 4448670480
当前线程 name【Thread-1】 ,id【123145390706688】,变量【4448670480】,等待第1秒
当前线程 name【Thread-2】 ,id【123145407496192】,变量【4448670480】,等待第1秒
当前线程 name【Thread-3】 ,id【123145424285696】,变量【4448670480】,等待第1秒
当前线程 name【Thread-1】 ,id【123145390706688】,变量【4448670480】,等待第2秒
当前线程 name【Thread-3】 ,id【123145424285696】,变量【4448670480】,等待第2秒
当前线程 name【Thread-2】 ,id【123145407496192】,变量【4448670480】,等待第2秒
线程已全部完成。

相关概念

并行与并发和串行

串行(Serial)

串行指的是任务依次执行,一个接一个地完成。在任何给定时间点,只有一个任务在进行。这种方式简单直观,但可能不够高效,因为它不能充分利用现代多核处理器的能力或者系统中同时存在的多个计算资源。

例如,在单核CPU上运行程序时,即使有多个程序需要执行,CPU也会选择其中一个程序先运行完毕后再运行下一个程序。

按顺序逐一完成。

并发(Concurrency)

并发是指系统具有处理多个任务的能力;这些任务可以交替执行,在单核处理器上通过时间片轮转来模拟同时进行。在多核处理器上,并发可以通过真正地同时运行不同线程/进程来实现。关键点是,并发强调了“同时处理”的概念而不一定是“同时完成”。

例如,在操作系统中经常出现并发情况:用户可能打开了浏览器、听音乐和下载文件等等。尽管用户感觉所有事情都在同一时间进行,但实际上操作系统正在快速地在各个应用之间切换资源分配(如果只有一个CPU核心)。

逻辑上同时处理多项任务。

并行(Parallelism)

并行指两个或更多任务实际上是在同一时刻被执行。这通常需要硬件支持如多核处理器。每个核心可以独立地执行不同的线程或进程,在物理层面真正达到了同时工作。

例如,在具有四个CPU核心的计算机上,并且每个内核都独立于其他内核工作时,则可以说该计算机支持四路并行性。

物理层面真正意义上的同时进行。

同步与异步、阻塞与非阻塞

  • 同步

    • 在同步操作中,任务的执行需要按照顺序一次完成。一个任务的完成可能依赖于前一个任务的结果。
    • 同步调用意味着在等待调用返回之前,调用者不能继续进行后续操作。即,执行某项操作时,必须等待这项操作完全结束后才能进行下一项操作。
  • 异步

    • 异步则允许多个任务几乎同时进行或交错进行,不必等待一个任务完全结束才开始另一个。
    • 异步调用意味着调用者可以在不立即得到结果的情况下发起一个调用,并且可以继续执行后续指令而不必阻塞等待结果。通常通过回调函数、事件、信号或其他机制来通知调用者结果。
  • 阻塞

    • 阻塞指当请求I/O时(如读写文件、网络通信等),如果数据未就绪,则进程/线程将被挂起(暂停执行),直到数据准备好再恢复运行。
    • 在此期间,CPU资源可以切换到其他进程/线程上去,但当前进程/线程无法做任何工作。
  • 非阻塞

    • 非阻塞则是指,在请求I/O操作时如果数据未就绪也不会导致进程/线程挂起;相反它会立即返回一个状态值表示当前无法完成该请求。
    • 使用非阻塞方式时,程序可以采取其他措施或再次尝试该请求而不是简单地等待。

关系和区别

  • 同步和异步关注点在于消息通信机制:同步强制要求发出一个功能调用时,在没有得到结果之前该呼叫不返回;而异步则允许在没有得到结果之前返回。

  • 阻塞和非阻塞性质主要描述了程序在等待某些事件(如I/O操作)完成时的状态:是否持续占据CPU并处于等待状态(即“被挂起”状态),还是立即返回以便处理其他事务。

概念描述示例
同步(Synchronous)执行操作时,必须等待操作完成才能继续执行后续任务。调用函数,该函数执行完毕并返回结果后,才进行下一行代码的执行。
异步(Asynchronous)执行操作时,不需要等待操作完成即可继续执行后续任务。发起网络请求,并立即返回。当数据到达时通过回调通知程序处理结果。
阻塞(Blocking)当请求资源不可用时,进程/线程挂起直到资源可用为止。使用标准输入函数input()等待用户输入,在用户未输入完成前程序无法继续运行。
非阻塞(Non-blocking)当请求资源不可用时,进程/线程不会挂起而是立即返回一个状态表示当前无法获取资源。尝试从非阻塞性套接字读取数据;如果没有数据可读,则立即得到通知并做其他处理。
  • 25
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序猿-瑞瑞

打赏不准超过你的一半工资哦~

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值