软件的核心载体是程序代码,软件开发的主要工作产出也是代码,但是代码被存储在磁盘上本身没有任何价值,软件要想实现价值,代码就必须运行起来。那么代码是如何运行的?在运行中可能会出现什么问题呢?
程序是如何运行起来的
软件被开发出来,是文本格式的代码,这些代码通常不能直接运行,需要使用编译器编译成操作系统或者虚拟机可以运行的代码,即可执行代码,它们都被存储在文件系统中。不管是文本格式的代码还是可执行的代码,都被称为程序,程序是静态的,安静地呆在磁盘上,什么也干不了。要想让程序处理数据,完成计算任务,必须把程序从外部设备加载到内存中,并在操作系统的管理调度下交给CPU去执行,去运行起来,才能真正发挥软件的作用,程序运行起来以后,被称作进程。
进程除了包含可执行的程序代码,还包括进程在运行期使用的内存堆空间、栈空间、供操作系统管理用的数据结构。如下图所示:

操作系统把可执行代码加载到内存中,生成相应的数据结构和内存空间后,就从可执行代码的起始位置读取指令交给CPU顺序执行。指令执行过程中,可能会遇到一条跳转指令,即CPU要执行的下一条指令不是内存中可执行代码顺序的下一条指令。编程中使用的循环for…,while…和if…else…最后都被编译成跳转指令。
程序运行时如果需要创建数组等数据结构,操作系统就会在进程的堆空间申请一块相应的内存空间,并把这块内存的首地址信息记录在进程的栈中。堆是一块无序的内存空间,任何时候进程需要申请内存,都会从堆空间中分配,分配到的内存地址则记录在栈中。
栈是严格的一个后进先出的数据结构,同样由操作系统维护,主要用来记录函数内部的局部变量、堆空间分配的内存空间地址等。
我们以如下代码示例,描述函数调用过程中,栈的操作过程:
void f(){
int x = g(1);
x++; //g函数返回,当前堆栈顶部为f函数栈帧,在当前栈帧继续执行f函数的代码。
}
int g(int x){
return x + 1;
}
每次函数调用,操作系统都会在栈中创建一个栈帧(stack frame)。正在执行的函数参数、局部变量、申请的内存地址等都在当前栈帧中,也就是堆栈的顶部栈帧中。如下图所示:

当f函数执行的时候,f函数就在栈顶,栈帧中存储着f函数的局部变量,输入参数等等。当f函数调用g函数,当前执行函数就变成g函数,操作系统会为g函数创建一个栈帧并放置在栈顶。当函数g()调用结束,程序返回f函数,g函数对应的栈帧出栈,顶部栈帧变又为f函数,继续执行f函数的代码,也就是说,真正执行的函数永远都在栈顶。而且因为栈帧是隔离的,所以不同函数可以定义相同的变量而不会发生混乱。
一台计算机如何同时处理数以百计的任务
我们自己日常使用的PC计算机通常只是一核或者两核的CPU,我们部署应用程序的服务器虽然有更多的CPU核心,通常也不过几核或者几十核。但是我们的PC计算机可以同时编程、听音乐,而且还能执行下载任务,而服务器则可以同时处理数以百计甚至数以千计的并发用户请求。
那么为什么一台计算机服务器可以同时处理数以百计,以千计的计算任务呢?这里主要依靠的是操作系统的CPU分时共享技术。如果同时有很多个进程在执行,操作系统会将CPU的执行时间分成很多份,进程按照某种策略轮流在CPU上运行。由于现代CPU的计算能力非常强大,虽然每个进程都只被执行了很短一个时间,但是在外部看来却好像是所有的进程都在同时执行,每个进程似乎都独占一个CPU执行。
所以虽然从外部看起来,多个进程在同时运行,但是在实际物理上,进程并不总是在CPU上运行的,一方面进程共享CPU,所以需要等待CPU运行,另一方面,进程在执行I/O操作的时候,也不需要CPU运行。进程在生命周期中,主要有三种状态,运行、就绪、阻塞。
- 运行:当一个进程在CPU上运行时,则称该进程处于运行状态。处于运行状态的进程的数目小于等于CPU的数目。
- 就绪:当一个进程获得了除CPU以外的一切所需资源,只要得到CPU即可运行,则称此进程处于就绪状态,就绪状态有时候也被称为等待运行状态。
- 阻塞:也称为等待或睡眠状态,当一个进程正在等待某一事件发生(例如等待I/O完成,等待锁……)而暂时停止运行,这时即使把CPU分配给进程也无法运行,故称该进程处于阻塞状态。
不同进程轮流在CPU上执行,每次都要进行进程间CPU切换,代价是非常大的,实际上,每个用户请求对应的不是一个进程,而是一个线程。线程可以理解为轻量级的进程,在进程内创建,拥有自己的线程栈,在CPU上进行线程切换的代价也更小。线程在运行时,和进程一样,也有三种主要状态,从逻辑上看,进程的主要概念都可以套用到线程上。我们在进行服务器应用开发的时候,通常都是多线程开发,理解线程对我们设计、开发软件更有价值。
系统为什么会变慢,为什么会崩溃
现在的服务器软件系统主要使用多线程技术实现多任务处理,完成对很多用户的并发请求处理。也就是我们开发的应用程序通常以一个进程的方式在操作系统中启动,然后在进程中创建很多线程,每个线程处理一个用户请求。
以Java的web开发为例,似乎我们编程的时候通常并不需要自己创建和启动线程,那么我们的程序是如何被多线程并发执行,同时处理多个用户请求的呢?实际中,启动多线程,为每个用户请求分配一个处理线程的工作是在web容器中完成的,比如常用的Tomcat容器。
如下图所示:

Tomcat启动多个线程,为每个用户请求分配一个线程,调用和请求URL路径相对应的Servlet(或者Controller)代码,完成用户请求处理。而Tomcat则在JVM虚拟机进程中,JVM虚拟机则被操作系统当做一个独立进程管理。真正完成最终计算的,是CPU、内存等服务器硬件,操作系统将这些硬件进行分时(CPU)、分片(内存)管理,虚拟化成一个独享资源让JVM进程在其上运行。
以上就是一个Java web应用运行时的主要架构,有时也被称作架构过程视图。需要注意的是,这里有个很多web开发者容易忽略的事情,那就是不管你是否有意识,你开发的web程序都是被多线程执行的,web开发天然就是多线程开发。
CPU以线程为单位进行分时共享执行,可以想象代码被加载到内存空间后,有多个线程在这些代码上执行,这些线程从逻辑上看,是同时在运行的,每个线程有自己的线程栈,所有的线程栈都是完全隔离的,也就是每个方法的参数和方法内的局部变量都是隔离的,一个线程无法访问到其他线程的栈内数据。
但是当某些代码修改内存堆里的数据的时候,如果有多个线程在同时执行,就可能会出现同时修改数据的情况,比如,两个线程同时对一个堆中的数据执行+1操作,最终这个数据只会被加一次,这就是人们常说的线程安全问题,实际上线程的结果应该是依次加一,即最终的结果应该是+2。
多个线程访问共享资源的这段代码被称为临界区,解决线程安全问题的主要方法是使用锁,将临界区的代码加锁,只有获得锁的线程才能执行临界区代码,如下:
lock.lock(); //线程获得锁
i++; //临界区代码,i位于堆中
lock.unlock(); //线程释放锁
如果当前线程执行到第一行,获得锁的代码的时候,锁已经被其他线程获取并没有释放,那么这个线程就会进入阻塞状态,等待前面释放锁的线程将自己唤醒重新获得锁。
锁会引起线程阻塞,如果有很多线程同时在运行,那么就会出现线程排队等待锁的情况,线程无法并行执行,系统响应速度就会变慢。此外I/O操作也会引起阻塞,对数据库连接的获取也可能会引起阻塞。目前典型的web应用都是基于RDBMS关系数据库的,web应用要想访问数据库,必须获得数据库连接,而受数据库资源限制,每个web应用能建立的数据库的连接是有限的,如果并发线程数超过了连接数,那么就会有部分线程无法获得连接而进入阻塞,等待其他线程释放连接后才能访问数据库,并发的线程数越多,等待连接的时间也越多,从web请求者角度看,响应时间变长,系统变慢。
被阻塞的线程越多,占据的系统资源也越多,这些被阻塞的线程既不能继续执行,也不能释放当前已经占据的资源,在系统中一边等待一边消耗资源,如果阻塞的线程数超过了某个系统资源的极限,就会导致系统宕机,应用崩溃。
解决系统因高并发而导致的响应变慢、应用崩溃的主要手段是使用分布式系统架构,用更多的服务器构成一个集群,以便共同处理用户的并发请求,保证每台服务器的并发负载不会太高。此外必要时还需要在请求入口处进行限流,减小系统的并发请求数;在应用内进行业务降级,减小线程的资源消耗。高并发系统架构方案将在专栏的第三模块中进一步探讨。
小结
事实上,现代CPU和操作系统的设计远比这篇文章讲的要复杂得多,但是基础原理大致就是如此。为了让程序能很好地被执行,软件开发的时候要考虑很多情况,为了让软件能更好地发挥效能,需要在部署上进行规划和架构。软件是如何运行的,应该是软件工程师和架构师的常识,在设计开发软件的时候,应该时刻以常识去审视自己的工作,保证软件开发在正确的方向上前进。
思考题
线程安全的临界区需要依靠锁,而锁的获取必须也要保证自己是线程安全的,也就是说,不能出现两个线程同时得到锁的情况,那么锁是如何保证自己是线程安全的呢?或者说,在操作系统以及CPU层面,锁是如何实现的?
你不妨思考一下这个问题,把你的思考写在下面的评论区里,我会和你一起交流。也欢迎你把这篇文章分享给你的朋友或者同事,一起交流一下。
精选留言
2019-11-18 18:58:42
2019-11-18 19:29:22
2019-11-18 21:27:03
小结:
1. 我们平时开发出来的程序是文本格式代码,但只是在硬盘中还只是一个程序,只有加载到内存里面通过cpu执行成为进程才是发挥了程序作用。
2.进程里面有堆,栈,可执行代码和进程数据结构。
3.cpu分时共享技术进行并发操作,进程切换效率不高,所以有了线程切换
4.因为线程安全问题引入锁,不过也引入了更多造成阻塞的可能
5.线程阻塞可能是I/O,锁,网络请求,数据库链接获取
6利用分布式系统架构来减缓高并发的性能不佳
2019-11-18 21:33:36
2019-11-21 07:20:59
1. 使用协程在出现IO等待时,程序会自己调度去执行其他的(CPU)任务。理论上这样可以避免额外的IO等待导致的线程间切换。我的问题是从系统的角度上看,使用协程可以抢占到更多的CPU时间片吗?
2. 感觉系统崩溃(除人为Bug外) 主要是系统资源不足导致的。那么即使用轻量级的协程也不会变得更好。因为当协程数量过多,导致event loop过大,变慢,系统还是要崩溃的对吗?
2019-11-19 08:51:46
1.解释执行。例子是脚本语言书写的程序或类似于BASIC语言书写的程序。著名的PYTHON也属于这种情况。
2.编译执行。通常C/C++程序属于这种情况。文本格式书写的程序称之为源程序,需要编译器编译成机器语言代码,称之为可执行程序一或目标程序。
3.虚拟机执行。将文本格式的程序先编译成一种中间代码,然后由驻留在计算机中的虚拟机解释执行。例子是通常的JAVA程序。
2019-11-19 23:46:43
## 程序是如何运行起来的?
- 1 、程序员被开发出来,文本格式代码,不能直接运行,需要编译器或者虚拟机先编译成机器码(也叫可执行代码),存储在文件系统中(即磁盘)。
- 2 、操作系统将编译好的代码加载到内存中。
- 3、CPU去执行,运行,程序运行起来被称作进程。
运行起来的程序,在运行期使用内存堆空间,栈空间,等数据结构。

程序运行时需要创建数组等数据结构,这些由操作系统在进程中分配。
进程的堆,栈中分配相应的空间,比如堆,堆的地址又存在栈中。
栈:一种数据结构,遵循先进后出,后进先出的顺序。
比如函数
```
void f(){
int x = g(1); x++; //g函数返回,当前堆栈顶部为f函数栈帧,在当前栈帧继续执行f函数的代码。
}
int g(int x){
return x + 1;
}
```
每次函数调用,操作系统都会在栈中创建一个栈帧,方法程序(函数)通常被压栈进栈,函数中的变量,内存都在当前栈帧中,如下图。

## 计算机如何同时处理数以百计的任务
通常情况下我们个人的PC 可能会是一核心或两核,现在基本都4核或者更高核cpu了
我们个人通常是用电脑会同时打开很多程序软件,听歌的,看电影的,下载视频等等几十个任务。
而服务器可以同时处理成千上百个任务以及并发请求而且一点儿都不卡顿(这里也存在服务器配置高的因素)
### 为什么电脑可以同时处理这么多的任务呢?
`这里主要依靠的是操作系统的 CPU 分时共享技术`
很多个进程在执行,操作系统会将 CPU 的执行时间分成很多份,进程按照某种策略轮流在 CPU 上运行
> 每个进程都只被执行了很短一个时间,但是在外部看来却好像是所有的进程都在同时执行,每个进程似乎都独占一个 CPU 执行
为什么每个程序不单独使用进程呢?
原因是因为CPU每个进程在CPU上轮训使用,消耗资源以及时间很大!
通常我们在打开自己电脑时候开了很多软件(或者进程)后明显的感觉自己电脑变慢了,卡顿了。
一方面是因为自己电脑内存小,另一方面是因为进程数太多了,cpu切换需要花费时间。
那么如何解决进程多cpu切换代价大的情况呢?
计算机的先辈们引入了线程的概念,比如我们开发中容器tomcat 每次用户请求时候,tomcat分配给用户一个线程,在进程里可以启动很多的线程,线程可以理解为轻量级的进程,在进程内创建,拥有自己的线程栈,在 CPU 上进行线程切换的代价也更小。
线程在运行时,和进程一样,也有三种主要状态
运行:当一个进程在 CPU 上运行时,则称该进程处于运行状态。处于运行状态的进程的数目小于等于 CPU 的数目。
就绪:当一个进程获得了除 CPU 以外的一切所需资源,只要得到 CPU 即可运行,则称此进程处于就绪状态,就绪状态有时候也被称为等待运行状态。
阻塞:也称为等待或睡眠状态,当一个进程正在等待某一事件发生(例如等待 I/O 完成,等待锁……)而暂时停止运行,这时即使把 CPU 分配给进程也无法运行,故称该进程处于阻塞状态。
## 系统为什么会变慢,为什么会崩溃
主要大致可以分为一下几个步骤原因
- 1、线程锁,引起线程阻塞,多线程情况下可能会存在线程排队等锁,县城无法并行执行导致速度变慢。
- 2、此外I/O阻塞同理,比如数据库连接,并行数量超过数据库连接数量,线程就会出现阻塞
- 3、等待其他县城释放连接才能访问数据库,并发越大等待连接越多,响应时间越长,系统越慢。
- 4、被阻塞的线程越多,占据的系统资源也越多,这些被阻塞的线程既不能继续执行,也不能释放当前已经占据的资源,在系统中一边等待一边消耗资源,如果阻塞的线程数超过了某个系统资源的极限,就会导致系统宕机,应用崩溃。
如何解决
- 1、采用请求限流
- 2 、集群架构
- 3、分布式架构
2019-11-19 07:48:30
2019-11-18 18:57:57
2019-12-03 05:01:42
单个进程或者线程消耗太多资源会让系统变慢甚至崩溃,更多的情况是,等待中的进程线程数目太多,无法释放资源而让系统变慢甚至崩溃。这就好比交通系统,一辆坦克车横冲直撞造成问题毕竟少,多数问题还是车多到一定数目,前面的车过不去,后面的车进不来,虽然,每辆车都遵守交通法则,但是还是造成了拥堵。这个时候,正常的交通规则已经不管用了,需要其他治理拥堵的特殊措施来处理。
2019-11-24 14:11:32
2021-01-19 10:09:26
老师,遇见你是我的幸运。
2019-11-20 18:51:28
2019-11-20 13:42:06
问题1:CPU 分时共享技术同时执行进程的数量,取决于什么?
问题2:为什么线程切换的代价更小?
问题3:进程切换是不是必须要等到线程切换完毕后进行?如果不是,优先级是由什么决定的?
一点小建议:
有一些表达程度的词,如果能用数据举例简单说明一下,对于我们理解会更有帮助。比如2问题中,代价更小,小到什么程度,是进程切换速度的几倍?
思考题:
作为小白,我的思路是这样,锁是在线程的临界区,线程是在进程的线程栈,而 一个 cpu 同时只能运行一个进程,所以本质上都是轮流执行的……于是,只要保证在获取锁的时候,锁不在正在获取或已经被获取的状态即可,进而推断线程中会有一片内存区域用来存这些状态信息。
😂不知道这个思路对不对。
最后谢谢老师。
2020-01-18 09:49:14
2019-11-20 18:39:06
1. 锁在内存中只有 1 份数据(JVM 用堆区中对象的 header 来实现,所以是每个线程都可见的),有开/关两个状态
2. 不同线程访问锁时一定是有先后顺序的(JMM 的 happens-before 原则有规定,这个可能涉及较多手段来保证,问题难点主要出现在多核 CPU 和 CPU cache 的情况下)
3. 如果线程遇到打开状态的锁,就“获取并关闭“锁(这里的原子性由 CPU 指令保证,就是 cas 机制)
更复杂的锁优化策略都在这个基础上实现
2023-01-07 11:14:10
2. 程序运行的单位是一个进程,操作系统为进程在内存中分配堆空间、栈空间;分配CPU时间片执行代码指令。
3. 程序变慢主要是锁、IO阻塞等竞争性的资源有限,过多的竞争导致“排队”阻塞。
4. 程序奔溃是变慢的一个无穷大的体现。资源已经完全不足,操作系统、甚至硬件为了自保的壁虎断尾操作。
2022-02-19 15:12:03
1. 必须要存在共享变量,CAS操作
2. 需要存储阻塞线程的队列,每次唤醒一个
3. 是否可以抢占(公平锁和非公平锁)
2021-04-19 09:15:32
2021-02-19 22:52:35