“进程是资源分配的最小单位,线程是CPU调度的最小单位”
上面这两句话,我相信很多人应该都听过,很多小白甚至把这两句话背下来了以此来应对面试,但是却缺乏对这两句话的理解,正好笔者最近在看操作系统的书和博客,于是想记录一下自己所学的东西,今天就让咱们来个感性的认识。
1. CPU 眼中的进程和线程是什么?
讲线程和进程之前,咱们先简单说说CPU,这样能让你对线程和进程有一个更加深的认识,其实 CPU 并没有线程、进程之类的概念。
CPU只知道两件事:
- 从内存中取出指令
- 执行指令,然后回到1
你看,如上图所示,在这里CPU确实是不知道什么进程、线程之类的。接下来的问题就是 CPU 从哪里取出指令呢?答案是来自一个被称为Program Counter(简称 PC )的寄存器,也就是我们熟知的程序计数器,在这里大家不要把寄存器想的太神秘,你可以简单的把寄存器理解为内存,只不过存取速度更快而已。PC寄存器中存放的是什么呢?这里存放的是指令在内存中的地址,什么指令呢?是CPU将要执行的下一条指令。
那么是谁来设置PC寄存器中的指令地址呢? 原来PC寄存器中的地址默认是自动加1的,这当然是有道理的,因为大部分情况下CPU都是一条接一条顺序执行,当遇到if、else时,这种顺序执行就被打破了,CPU在执行这类指令时会根据计算结果来动态改变PC寄存器中的值,这样CPU就可以正确的跳转到需要执行的指令了。聪明的你一定会问,那么PC中的初始值是怎么被设置的呢?在回答这个问题之前我们需要知道CPU执行的指令来自哪里?是来自内存,废话,内存中的指令是从磁盘中保存的可执行程序加载过来的,磁盘中可执行程序是编译器生成的,编译器又是从哪里生成的 机器指令呢?答案就是我们定义的函数/方法。
注意是函数/方法,函数被编译后才会形成CPU执行的指令,那么很自然的,我们该如何让CPU执行一个函数呢?显然我们只需要找到函数被编译后形成的第一条指令就可以了,第一条指令就是函数入口。现在你应该知道了吧,我们想要CPU执行一个函数,那么只需要把该函数对应的第一条机器指令的地址写入PC寄存器就可以了,这样我们写的函数就开始被CPU执行起来啦。
你可能会有疑问,这和线程有什么关系呢?
2. 从 CPU 到操作系统
上一小节中我们明白了 CPU 的工作原理,我们想让 CPU 执行某个函数,那么只需要把函数对应的第一条机器执行装入 PC 寄存器就可以了(再次强调),这样即使没有操作系统我们也可以让 CPU 执行程序,虽然可行但这是一个非常繁琐的过程,我们需要:
- 在内存中找到一块大小合适的区域装入程序
- 并且找到函数入口,设置好 PC 寄存器让 CPU 开始执行程序
这两个步骤绝不是那么容易的事情,如果每次在执行程序时程序员自己手动实现上述两个过程会疯掉的,因此聪明的程序员就会想干脆直接写个程序来自动完成上面两个步骤吧。机器指令需要加载到内存中执行,因此需要记录下内存的起始地址和长度;同时要找到函数的入口地址并写到PC寄存器中,于是我们就弄了个数据结构来记录下这些信息,例如像下面这样:
//C语言
struct *** {
void* start_addr;
int len;
void* start_point;
};
接下来就是起名字时刻。这个数据结构总要有个名字吧,干脆就叫进程(Process)好了,就这样进程诞生了。CPU 执行的第一个函数也起个名字,第一个要被执行的函数听起来比较重要,干脆就叫 main 函数吧。
完成上述两个步骤的程序也要起个名字,这个“简单”的程序就叫操作系统
(Operating System)好啦。就这样操作系统诞生了,程序员再也不用自己手动加载可执行程序了。现在进程和操作系统都有了,一切看上去都很完美。
3. 从单核到多核,如何充分利用多核
人类的一大特点就是生命不息折腾不止,从单核折腾到了多核。这时,假设我们想写一个程序并且要充分利用多核该怎么办呢?
有的同学可能会说不是有进程吗,多开几个进程不就可以了?这样多个进程就可以在多个核心上同时运行,在一听上去似乎很有道理,但是主要存在这样几个问题:
- 进程是需要占用内存空间的(从上一节能看到这一点),如果多个进程基于同一个可执行程序,那 么这些进程其内存区域中的内容几乎完全相同,这显然会造成内存的浪费
- 计算机处理的任务可能是比较复杂的,这就涉及到了进程间通信,由于各个进程处于不同的内存地址空间,进程间通信天然需要借助操作系统,这就在增大编程难度的同时也增加了系统开销
那该怎么办呢?
4. 从进程到线程
让我再来仔细的想一想这个问题,所谓进程无非就是内存中的一段区域,这段区域中保存了 CPU 执行的机器指令以及函数运行时的堆栈信息,要想让进程运行,就把main函数的第一条机器指令地址写入 PC 寄存器,这样进程就运行起来了。
进程的缺点在于只有一个入口函数,也就是 main 函数,因此进程中的机器指令只能被一个CPU执行,那么有没有办法让多个 CPU 来执行同一个进程中的机器指令呢?聪明的你应该能想到,既然我们可以把main函数的第一条指令地址写入PC寄存器,那么其它函数和 main 函数又有什么区别呢?
答案是没什么区别,main 函数的特殊之处无非就在于是 CPU 执行的第一个函数,除此之外再无特别之处,我们可以把 PC 寄存器指向 main 函数,就可以把 PC 寄存器指向任何一个函数。我们把 PC 寄存器指向非 main 函数时,线程就诞生了。
至此我们解放了思想,一个进程内可以有多个入口函数,也就是说属于同一个进程中的机器指令可以被多个 CPU 同时执行。
注意,这是一个和进程不同的概念,创建进程时我们需要在内存中找到一块合适的区域以装入进程,然后把 CPU 的 PC 寄存器指向 main 函数,也就是说进程中只有一个执行流。但是现在不一样了,多个CPU可以在同一个屋檐下(进程占用的内存区域)同时执行属于该进程的多个入口函数,也就是说现在一个进程内可以有多个执行流了。然后我们把这个执行流就叫线程吧。这就是线程的由来。
操作系统为每个进程维护了一堆信息,用来记录进程所处的内存空间等,这堆信息记为数据集A。同样的,操作系统也需要为线程维护一堆信息,用来记录线程的入口函数或者栈信息等,这堆数据记为数据集B。
显然数据集 B 要比数据 A 的量要少,同时不像进程,创建一个线程时无需去内存中找一段内存空间,因为线程是运行在所处进程的地址空间的,读到这里不知道你有没有理解文章开头的第一句话【进程是资源分配的最小单位】,当然,这也是为什么各种教材上提的创建线程要比创建进程快的原因之一(还有其它原因)。值得注意的是,有了线程这个概念后,我们只需要开启一个进程并创建多个线程就可以让所有CPU都忙起来,这就是所谓高性能、高并发的根本所在。
另外值得注意的一点是,由于各个线程共享进程的内存地址空间,因此线程之间的通信无需借助操作系统,这给程序员带来极大方便的同时也带来了无尽的麻烦,多线程遇到的多数问题都出自于线程间通信简直太方便了以至于非常容易出错。出错的根源在于 CPU 执行指令时根本没有线程的概念,多线程编程面临的互斥与同步问题需要程序员自己解决,这就又聊到了常见的多线程相关的一些知识,有关这部分我后面的文章还会分享。
我看到网上有个面试题,就是说单核能不能用多线程,答案是肯定的,在单核的情况下一样可以创建出多个线程,原因在于线程是操作系统层面的实现,和有多少个核心是没有关系的,CPU只会傻傻的执行指令,是意识不到执行的机器指令属于哪个线程的。
5. 线程与内存
我们知道了线程和 CPU 的关系,也就是把 CPU 的 PC 寄存器指向线程的入口函数,这样线程就可以运行起来了,这就是为什么我们创建线程时必须指定一个入口函数的原因。那么线程和内存又有什么关联呢?我们知道函数在被执行的时产生的数据包括函数参数、局部变量、返回地址等信息,这些信息是保存
在栈中的,线程这个概念还没有出现时进程中只有一个执行流,因此只有一个栈,这个栈的栈底就是进程的入口函数,也就是 main 函数,那么有了线程以后了呢?有了线程以后一个进程中就存在多个执行入口,即同时存在多个执行流,那么只有一个执行流的进程需要一个栈来保存运行时信息,那么很显然有多个执行流时就需要有多个栈来保存各个执行流的信息,也就是说操作系统要为每个线程在进程的地址空间中分配一个栈,即每个线程都有独属于自己的
栈,能意识到这一点是极其关键的。
同时我们也可以看到,创建线程是要消耗进程内存空间的,这一点也值得注意。
6. 线程的使用
现在有了线程的概念,那么接下来作为程序员我们该如何使用线程呢?从声明周期的角度讲,线程要处理的任务有两类:长任务和短任务。
**长任务:**顾名思义,就是任务存活的时间很长,比如以我们常用的word为例,我们在word中编辑的
文字需要保存在磁盘上,往磁盘上写数据就是一个任务,那么这时一个比较好的方法就是专门创建一个写磁盘的线程,该写线程的生命周期和word进程是一样的,只要打开word就要创建出该写线程,当用户关闭word时该线程才会被销毁,这就是长任务。
这种场景非常适合创建专用的线程来处理某些特定任务,这种情况比较简单。
有长任务,相应的就有短任务。
短任务:这个概念也很简单,那就是任务的处理时间很短,比如一次网络请求、一次数据库查询等,这种任务可以在短时间内快速处理完成。因此短任务多见于各种Server,像web server、database server、file server、mail server等,这也是互联网行业的同学最常见的场景,这种场景是我们要重
点讨论的。
这种场景有两个特点:一个是任务处理所需时间短;另一个是任务数量巨大。
如果让你来处理这种类型的任务该怎么办呢?
你可能会想,这很简单啊,当server接收到一个请求后就创建一个线程来处理任务,处理完成后销毁该线程即可,So easy
如果是长任务,那么这种方法可以工作的很好,但是对于大量的短任务这种方法虽然实现简单但是有这样几个缺点:
- 从前几节我们能看到,线程是操作系统中的概念(这里不讨论用户态线程实现、协程之类),因此创建线程天然需要借助操作系统来完成,操作系统创建和销毁线程是需要消耗时间的
- 每个线程需要有自己独立的栈,因此当创建大量线程时会消耗过多的内存等系统资源
7. 从多线程到线程池
线程池的概念是非常简单的,无非就是创建一批线程,之后就不再释放了,有任务就提交给这些线程处理,因此无需频繁的创建、销毁线程,同时由于线程池中的线程个数通常是固定的,也不会消耗过多的内存,因此这里的思想就是复用、可控。
线程池是如何工作的呢?
可能有的同学会问,该怎么给线程池提交任务呢?这些任务又是怎么给到线程池中线程呢?很显然,数据结构中的队列天然适合这种场景,提交任务的就是生产者,消费任务的线程就是消费者,实际上这就是经典的生产者-消费者问题,注意哦,现在互联网有些面试会让你手写一个生产者-消费者
现在你应该知道为什么操作系统课程要讲、面试要问这个问题了吧,因为如果你对生产者-消费者问题不理解的话,本质上你是无法正确的写出线程池的。
一般来说提交给线程池的任务包含两部分:
1) 需要被处理的数据;2) 处理数据的函数
struct task {
void* data; // 任务所携带的数据
handler handle; // 处理数据的方法
}
线程池中的线程会阻塞在队列上,当生产者向队列中写入数据后,线程池中的某个线程会被唤醒,该线程从队列中取出上述结构体,执行:
while(true) {
struct task = GetFromQueue(); // 从队列中取出数据
task->handle(task->data); // 处理数据
}
线程池中线程的数量 :
现在线程池有了,那么线程池中线程的数量该是多少呢?在接着往下看前先自己想一想这个问题。如果你能看到这里说明还没有睡着。
要知道线程池的线程过少就不能充分利用 CPU ,线程创建的过多反而会造成系统性能下降,内存占用过多,线程切换造成的消耗等等。因此线程的数量既不能太多也不能太少,那到底该是多少呢?
这又回到了老生长谈的一个问题,也是面试高频的一个问题,即线程池的参数怎么配置?回答这个问题,你需要知道线程池处理的任务有哪几类,有的同学可能会说你不是说有两类吗?长任务和短任务,这个是从生命周期的角度来看的,那么从处理任务所需要的资源角度看也有两种类型,分别是 CPU 密集型和 I/O 密集型。
CPU密集型:
所谓CPU密集型就是说处理任务不需要依赖外部I/O,比如科学计算、矩阵运算等等。在这种情况下只要线程的数量和核数基本相同就可以充分利用CPU资源。
I/O密集型:
这一类任务可能计算部分所占用时间不多,大部分时间都用在了比如磁盘I/O、网络I/O等,这种情况下就稍微复杂一些了,你需要利用性能测试工具评估出用在I/O等待上的时间,这里记为WT(wait time),以及 CPU 计算所需要的时间,这里记为CT (computing time),那么对于一个 N 核的系统,合适的线程数大概是N * (1 + WT/CT),假设 I/O 等待时间和计算时间相同,那么你大概需要2N个线程才能,充分利用CPU资源,注意这只是一个理论值,具体设置多少需要根据真实的业务场景进行测试。
注意:上面说的都是经验值,实际的参数配置一定是基于当前业务进行压测!
当然充分利用 CPU 不是唯一需要考虑的点,随着线程数量的增多,内存占用、系统调度、打开的文件数量、打开的 socket 数量以及打开的数据库链接等等是都需要考虑的。
线程池不是万能的,线程池仅仅是多线程的一种使用形式,因此多线程面临的问题线程池同样不能避免,像死锁问题、 race condition问题等等
8. 线程池使用的最佳实践
线程池是程序员手中强大的武器,互联网公司的各个 server 上几乎都能见到线程池的身影,使用线程池前你需要考虑:充分理解你的任务,是长任务还是短任务、是 CPU 密集型还是 I/O 密集型,如果两种都有,那么一种可能更好的办法是把这两类任务放到不同的线程池中,这样也许可以更好的确定线程数量
如果线程池中的任务有 I/O 操作,那么务必对此任务设置超时,否则处理该任务的线程可能会一直阻塞下去,线程池中的任务最好不要同步等待其它任务的结果
总结
上面的文章分了一些线程、进程的知识,其实文章里提到的许多东西还有很多相关拓展,比如说 I/O模型、异步、回调、协程等等,后面我会把这些相关的东西都分享出来。