进程、线程、协程
文章目录
一、进程的出现
CPU是不知道进程、线程的概念的,CPU只知道做两件事情。
- 从内存中读取指令;
- 执行指令,然后继续读取指令。
CPU是从PC(程序计数器)中读取指令的。那么这个PC是什么呢?PC其实存储的是一个内存地址,一个指向内存中下一条要执行指令的地址。
那么PC的初始值是怎么设置的呢?
我们知道CPU要执行的指令来自内存,内存中的指令是从磁盘加载而来的,磁盘中的指令是编译器生成的,编译器又是从哪生成的机器指令呢?答案是我们定义的函数。
函数被编译后会生成供 CPU 执行的机器指令。那么,如何让 CPU 执行这些指令呢?
显然,我们只需要将编译后的函数的第一条指令的地址放入程序计数器(PC)中。可是,这和进程或线程又有什么关系呢?
为了执行这些机器指令,我们首先需要将它们加载到内存中,并确保设置了正确的程序入口地址。要完成这两个步骤,完全依靠人工是非常繁琐的,因此,聪明的程序员决定编写程序来完成这个过程。
具体而言,机器指令需要被加载到内存中执行,同时需要记录内存的起始地址和长度,确保函数的入口地址被正确设置并写入程序计数器(PC)。为此,我们需要一个结构体来存储这些信息。
type xxx struct{
strat_addr pointer
len int
start_point pointer
}
这个数据结构总得有个名字吧?它用来记录程序在加载到内存中的运行状态,确保程序能够从磁盘被正确加载并运行。于是,程序从磁盘加载到内存并开始执行的状态,就可以称为进程(Process)。
至于 CPU 执行的第一个函数,我们不妨也给它取个响亮的名字。既然它是程序执行的起点,那就叫它 main
函数吧。
最后,这个能把进程从磁盘加载到内存并开始执行的程序也需要个名字。这样,操作系统就诞生了,程序员再也不需要手动去加载程序,一切都自动化了。
二、线程的出现
进程占用了内存中的一块区域,这段区域保存了 CPU 执行的机器指令以及函数运行时的堆栈信息。为了让程序开始执行,我们只需要将 main 函数的第一条指令地址写入程序计数器(PC)中。
既然程序计数器可以指向 main 函数,那么它当然也可以指向其他函数。
没错,当我们将程序计数器指向非 main 函数时,线程 就诞生了。这样,进程中的机器指令就不再局限于单一的入口函数,进程的执行可以通过多个线程并行在不同的内核上执行。
至此,一个进程中的代码可以被多个内核同时执行,从而实现更高效的并行计算。
通过引入线程,多个 CPU 核心可以共享同一个进程的内存空间,并在不同的线程中并行执行不同的任务,这就是现代计算机多核处理的基本原理。
线程处理的任务通常分为两类:
-
长任务:这类任务通常涉及到磁盘操作,如向磁盘写入数据等。为了提高效率,通常会专门为这些长任务创建独立的线程来执行,避免阻塞主线程。
-
短任务:短任务比较常见,比如一次网络请求、一次数据库查询等。虽然这些任务本身耗时较短,但如果任务数量庞大,频繁创建和销毁线程会导致性能下降。
创建和销毁线程是需要消耗时间和系统资源的,每个线程还需要自己的栈空间,这可能导致过多的内存消耗。为了解决这个问题,我们引入了线程池,这是一种能够复用线程并有效管理系统资源的机制。
线程池中的核心思想是:通过复用线程来减少频繁创建和销毁线程的开销。
线程池有两个主要部分需要处理:
- 任务数据:任务需要处理的数据。
- 处理函数:负责处理任务的函数。
线程池中的线程会处于阻塞状态,等待从任务队列中获取任务。当生产者向队列中写入任务时,线程池中的某个线程会被唤醒,然后从队列中取出任务,并使用任务数据作为参数调用处理函数。
具体执行流程如下:
while(true) {
struct task = GetFromQueue(); // 从队列中取出任务
task->handle(task->data); // 使用任务中的数据调用处理函数
}
线程池的核心部分:
- 任务队列:保存待处理的任务,每个任务通常包含需要处理的数据和一个处理该数据的函数。
- 线程池中的线程:这些线程在初始化时就已经创建好,线程处于阻塞状态,等待任务的到来。当有任务加入队列时,线程被唤醒,从队列中取出任务并处理。
- 任务处理:线程从队列中取出任务后,调用任务对应的处理函数来执行具体的操作。
三、协程
协程是如何实现的:
从协程的本质出发,它可以看作是一个能够被暂停并恢复执行的函数。
要理解这一点,我们可以类比到篮球比赛的暂停。在比赛过程中,裁判可以随时暂停比赛,并记录下比赛状态(比如球的位置、各个球员的位置等),当比赛重新开始时,裁判只需恢复这些记录的状态,比赛就可以继续进行,就像从未被暂停一样。
这就是协程的核心思想:在执行过程中,协程会保存自己的上下文,并在需要时恢复这些上下文,从而从上次暂停的地方继续执行。
协程与函数的区别
普通函数的执行是线性的,一旦开始执行,直到函数返回时,程序才会继续执行其他任务。而协程不同,它可以在执行的任意时刻通过 yield
暂停,保存当前执行状态(即上下文),然后返回给调用者。当协程需要恢复时,它会从上次暂停的位置继续执行,而不是从头开始。
协程如何实现?
函数的运行状态(上下文):
在普通函数执行时,所有的局部变量、调用栈等信息都存储在栈中(栈帧)。如果我们要暂停函数的执行,就需要保存当前函数的执行状态,这个状态即为函数的上下文。
从图中我们可以看出,该进程中只有一个线程,栈区中有四个栈帧,main函数调用A函数,A函数调用B函数,B函数调用C函数,当C函数在运行时整个进程的状态就如图所示。
既然函数的运行时状态保存在栈区的栈帧中,那么如果我们想暂停协程的运行就必须保存整个栈帧的数据,那么我们该将整个栈帧中的数据保存在哪里呢?
在协程被暂停时,它的执行状态(即栈帧)需要被保存。栈通常是在栈区分配的,但为了能在多个执行流之间切换,协程的栈帧需要存储在堆区中,这样可以在协程切换时,保持其状态。栈区用来存储当前执行线程的栈帧,而堆区则为协程提供更灵活的存储空间。
从图中我们可以看到,该程序中开启了两个协程,这两个协程的栈区都是在堆上分配的,这样我们就可以随时中断或者恢复协程的执行了。
因为堆区是用于长时间存储数据的内存区域,我们可以在堆区为每个协程动态分配栈空间。当协程被暂停时,整个栈帧的内容被保存到堆区。当协程恢复时,操作系统不需要再次复制数据,而是直接从堆区加载保存的栈帧,恢复协程的执行状态。
协程与线程的区别
与线程不同,协程的调度和切换完全发生在用户空间,并不需要操作系统干预。协程的切换开销远小于线程,因为它们不需要涉及内核态和用户态之间的切换。协程通常在堆上分配栈空间,因此可以实现大量并发执行流,而不需要为每个执行流创建一个线程。
多个执行流但只有一个线程
当你创建了多个协程时,操作系统并不“看到”这些协程,因为它们本质上运行在单一线程内。协程的切换完全由程序员控制,通过调度机制实现任务的交替执行。因此,即使创建了多个协程,操作系统看到的仍然是一个线程,而协程的调度、切换则由程序内部管理。
为什么要使用协程?
-
高效的并发处理:
协程相较于线程有更低的内存和时间开销。通过在用户空间管理并发任务,程序员能够灵活地控制协程的调度和切换,而不依赖操作系统的线程调度。
-
没有线程开销:
线程的创建和切换涉及大量的操作系统开销,而协程的切换仅需要保存和恢复执行上下文,切换开销小得多。这意味着协程适用于需要高并发的场景,可以在不增加大量线程开销的情况下,模拟多个执行流。
-
灵活的执行流管理:
程序员可以控制何时暂停和恢复协程执行,使得协程能够高效地完成并发任务,而不需要操作系统干预。这使得协程特别适用于事件驱动或异步编程模式。
本篇文章总结于以下文章:
标签:协程,函数,任务,线程,内存,进程,执行 From: https://blog.csdn.net/m0_73337964/article/details/143821382