CPU、操作系统、线程与线程池

本文详细介绍了CPU执行指令的基本原理,从进程、线程的概念到多核时代的线程池。CPU通过PC寄存器执行指令,而操作系统简化了程序执行的繁琐过程。线程是同一进程中的多个执行流,允许多个函数同时执行,线程池则解决了频繁创建和销毁线程的问题,通过固定数量的线程高效处理任务。文章还探讨了线程池中线程数量的合理配置以及使用线程池时的注意事项。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

CPU、操作系统、线程与线程池

CPU原理

首先我们要思考的一个问题是:线程、进程和CPU是什么关系呢?

实际情况是:CPU并不知道线程、进程之类的概念。

CPU只知道两件事:

  1. 从内存中取出指令;
  2. 执行指令,然后回到 1)。

cpu

由此,在这里CPU确实是不知道什么进程、线程之类的概念。

那么接下来的问题就是具体CPU从哪里取出指令呢?答案是来自一个被称为Program Counter(简称PC)的寄存器,也就是我们熟知的程序计数器,在这里大家不要把寄存器想的太神秘,你可以简单的把寄存器理解为内存,只不过存取速度更快而已。

PC寄存器中存放的是指令在内存中的地址,即CPU将要执行的下一条指令。

那么是谁来设置PC寄存器中的指令地址呢?

默认PC寄存器中的地址默认是自动加1的,这当然是有道理的,因为大部分情况下CPU都是一条接一条顺序执行,当遇到if、else时,这种顺序执行就被打破了,CPU在执行这类指令时会根据计算结果来动态改变PC寄存器中的值,这样CPU就可以正确的跳转到需要执行的指令了。

那么PC中的初始值是怎么被设置的呢?

我们知道CPU执行的指令来自内存,内存中的指令是从磁盘中保存的可执行程序加载过来的,磁盘中可执行程序是编译器生成的,编译器又是从哪里生成的机器指令呢?答案就是我们定义的函数(程序)

在这里插入图片描述

注意是函数,函数被编译后才会形成CPU执行的指令,那么很自然的,我们该如何让CPU执行一个函数呢?显然我们只需要找到函数被编译后形成的第一条指令就可以了,第一条指令就是函数入口。

现在我们知道了吧,我们想要CPU执行一个函数,那么只需要把该函数对应的第一条机器指令的地址写入PC寄存器就可以了,这样我们写的函数就开始被CPU执行起来啦。

从CPU到操作系统

上面我们明白了CPU的工作原理,想让CPU执行某个函数,那么只需要把函数对应的第一条机器指令装入PC寄存器就可以了,这样即使没有操作系统我们也可以让CPU执行程序,虽然可行但这是一个非常繁琐的过程。

我们需要:

  1. 在内存中找到一块大小合适的区域装入程序;
  2. 找到函数入口,设置好PC寄存器让CPU开始执行程序。

这两个步骤绝不是那么容易的事情,如果每次在执行程序时程序员自己手动实现上述两个过程会疯掉的,因此聪明的程序员就会想干脆直接写个程序来自动完成上面两个步骤吧。

机器指令需要加载到内存中执行,因此需要记录下内存的起始地址和长度;同时要找到函数的入口地址并写到PC寄存器中,想一想这是不是需要一个数据结构来记录下这些信息。

数据结构大致如下:

struct *** {
   void* start_addr;
   int len;
    
   void* start_point;
   ...
};

我们知道这个结构体用来纪录的是程序在被加载到内存中的运行状态,程序从磁盘加载到内存跑起来所需保存的,这就是进程。
CPU执行的第一个函数也起个名字,第一个要被执行的函数听起来比较重要,所以叫main 函数
完成上述两个步骤的程序就是“操作系统”

从单核到多核,如何充分利用多核

通过若干年的发展,从单核折腾到了多核。

这时,假设我们想写一个程序并且要分利用多核该怎么办呢?

有的同学可能会说不是有进程吗,多开几个进程不就可以了?

听上去似乎很有道理,但是主要存在这样几个问题:

  • 1)进程是需要占用内存空间的(从上一节能看到这一点),如果多个进程基于同一个可执行程序,那么这些进程其内存区域中的内容几乎完全相同,这显然会造成内存的浪费;
  • 2)计算机处理的任务可能是比较复杂的,这就涉及到了进程间通信,由于各个进程处于不同的内存地址空间,进程间通信天然需要借助操作系统,这就在增大编程难度的同时也增加了系统开销。
从进程到线程

让我们再来仔细的想一想这个问题,所谓进程无非就是内存中的一段区域,这段区域中保存了CPU执行的机器指令以及函数运行时的堆栈信息,要想让进程运行,就把main函数的第一条机器指令地址写入PC寄存器,这样进程就运行起来了。

在这里插入图片描述

进程的缺点在于只有一个入口函数,也就是main函数,因此进程中的机器指令只能被一个CPU执行,那么有没有办法让多个CPU来执行同一个进程中的机器指令呢?

既然我们可以把main函数的第一条指令地址写入PC寄存器,那么其它函数和main函数又有什么区别呢?

答案是没什么区别,main函数的特殊之处无非就在于是CPU执行的第一个函数,除此之外再无特别之处,我们可以把PC寄存器指向main函数,就可以把PC寄存器指向任何一个函数。

当我们把PC寄存器指向非main函数时,线程就诞生了。

在这里插入图片描述

注意:这是一个和进程不同的概念,创建进程时我们需要在内存中找到一块合适的区域以装入进程,然后把CPU的PC寄存器指向main函数,也就是说进程中只有一个执行流.
但是现在不一样了,多个CPU可以在同一个屋檐下(进程占用的内存区域)同时执行属于该进程的多个入口函数,也就是说现在一个进程内可以有多个执行流了。
这就是线程的由来。

由于各个线程共享进程的内存地址空间,因此线程之间的通信无需借助操作系统,这给程序员带来极大方便的同时也带来了无尽的麻烦,多线程遇到的多数问题都出自于线程间通信简直太方便了以至于非常容易出错。出错的根源在于CPU执行指令时根本没有线程的概念,多线程编程面临的互斥与同步问题需要程序员自己解决。

最后需要提醒的是:虽然前面关于线程讲解使用的图中用了多个CPU,但不是说一定要有多核才能使用多线程,在单核的情况下一样可以创建出多个线程,原因在于线程是操作系统层面的实现,和有多少个核心是没有关系的,CPU在执行机器指令时也意识不到执行的机器指令属于哪个线程。即使在只有一个CPU的情况下,操作系统也可以通过线程调度让各个线程“同时”向前推进,方法就是将CPU的时间片在各个线程之间来回分配,这样多个线程看起来就是“同时”运行了,但实际上任意时刻还是只有一个线程在运行。

线程与内存

在前面的讨论中我们知道了线程和CPU的关系,也就是把CPU的PC寄存器指向线程的入口函数,这样线程就可以运行起来了,这就是为什么我们创建线程时必须指定一个入口函数的原因。

无论使用任何编程语言,创建一个线程大体相同:

// 设置线程入口函数DoSomething
thread = CreateThread(DoSomething);
 
// 让线程运行起来
thread.Run();

那么线程和内存又有什么关联呢?

我们知道函数在被执行的时产生的数据包括:函数参数、局部变量、返回地址等信息。这些信息是保存在栈中的,线程这个概念还没有出现时进程中只有一个执行流,因此只有一个栈,这个栈的栈底就是进程的入口函数,也就是main函数。

假设main函数调用了funA,funcA又调用了funcB,如图所示:

在这里插入图片描述

有了线程以后一个进程中就存在多个执行入口,即同时存在多个执行流,那么只有一个执行流的进程需要一个栈来保存运行时信息,那么很显然有多个执行流时就需要有多个栈来保存各个执行流的信息,也就是说操作系统要为每个线程在进程的地址空间中分配一个栈,即每个线程都有独属于自己的栈.

在这里插入图片描述

同时我们也可以看到,创建线程是要消耗进程内存空间的

线程的使用

现在有了线程的概念,那么接下来我们该如何使用线程呢

从生命周期的角度讲,线程要处理的任务有两类:长任务和短任务。

1)长任务(long-lived tasks):

顾名思义,就是任务存活的时间很长,比如以我们常用的word为例,我们在word中编辑的文字需要保存在磁盘上,往磁盘上写数据就是一个任务,那么这时一个比较好的方法就是专门创建一个写磁盘的线程,该写线程的生命周期和word进程是一样的,只要打开word就要创建出该写线程,当用户关闭word时该线程才会被销毁,这就是长任务。这种场景非常适合创建专用的线程来处理某些特定任务.

2)短任务(short-lived tasks):

短任务即任务的处理时间很短,比如一次网络请求、一次数据库查询等,这种任务可以在短时间内快速处理完成。因此短任务多见于各种Server,像web server、database server、file server、mail server等。这种场景有两个特点:一个是任务处理所需时间短;另一个是任务数量巨大。

对于这种类型的任务,如果我们采用像上面一样的处理,即当server接收到一个请求后就创建一个线程来处理任务,处理完成后销毁该线程的方法,会存在两个问题:

  • 消耗大量的操作系统创建和销毁线程时间(这里不讨论用户态线程实现、协程之类)。
  • 每个线程需要有自己独立的栈,因此当创建大量线程时会消耗过多的内存等系统资源

因此比较好的方法是我们需要一个线程池,创建一批线程,之后就不再释放了,有任务就提交给这些线程处理,因此无需频繁的创建、销毁线程,同时由于线程池中的线程个数通常是固定的,也不会消耗过多的内存。(复用、可控)

从多线程到线程池

线程池的工作原理其实就是经典的生产者-消费者模型,维护一个队列数据结构,提交任务的就是生产者,消费任务的线程就是消费者。

一般来说提交给线程池的任务包含两部分:

  1. 需要被处理的数据;
  2. 处理数据的函数。
struct task {
    void* data;     // 任务所携带的数据
    handler handle; // 处理数据的方法
}

线程池中的线程会阻塞在队列上,当生产者向队列中写入数据后,线程池中的某个线程会被唤醒,该线程从队列中取出上述结构体(或者对象),以结构体(或者对象)中的数据为参数并调用处理函数。简单示例即:

while(true) {
  struct task = GetFromQueue(); // 从队列中取出数据
  task->handle(task->data);     // 处理数据
}
线程池中线程的数量

现在思考一下线程池中线程的数量该是多少呢?

要知道线程池的线程过少就不能充分利用CPU,线程创建的过多反而会造成系统性能下降,内存占用过多,线程切换造成的消耗等等。因此线程的数量既不能太多也不能太少,那到底该是多少呢?

回答这个问题,需要知道线程池处理的任务有哪几类(从所需资源的角度):

1)cpu密集型
所谓CPU密集型就是说处理任务不需要依赖外部I/O,比如科学计算、矩阵运算等等。在这种情况下只要线程的数量和核数基本相同就可以充分利用CPU资源。

2)I/O密集型:
这种情况下就稍微复杂一些了,你需要利用性能测试工具评估出用在I/O等待上的时间,这里记为WT(wait time),以及CPU计算所需要的时间,这里记为CT(computing time),那么对于一个N核的系统,合适的线程数大概是 N * (1 + WT/CT) (由于通常I/O相对比cpu计算费时,等待时间更长),假设I/O等待时间和计算时间相同,那么你大概需要2N个线程才能充分利用CPU资源

当然充分利用CPU不是唯一需要考虑的点,随着线程数量的增多,内存占用、系统调度、打开的文件数量、打开的socker数量以及打开的数据库链接等等是都需要考虑的

线程池的思考

使用线程池前需要考虑:

1)充分理解你的任务,是长任务还是短任务、是CPU密集型还是I/O密集型,如果两种都有,那么一种可能更好的办法是把这两类任务放到不同的线程池中,这样也许可以更好的确定线程数量;
2)如果线程池中的任务有I/O操作,那么务必对此任务设置超时,否则处理该任务的线程可能会一直阻塞下去;
3)线程池中的任务最好不要同步等待其它任务的结果。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值