gitbook/后端技术面试 38 讲/docs/166581.md
2022-09-03 22:05:03 +08:00

11 KiB
Raw Blame History

01丨程序运行原理程序是如何运行又是如何崩溃的

软件的核心载体是程序代码,软件开发的主要工作产出也是代码,但是代码被存储在磁盘上本身没有任何价值,软件要想实现价值,代码就必须运行起来。那么代码是如何运行的?在运行中可能会出现什么问题呢?

程序是如何运行起来的

软件被开发出来,是文本格式的代码,这些代码通常不能直接运行,需要使用编译器编译成操作系统或者虚拟机可以运行的代码,即可执行代码,它们都被存储在文件系统中。不管是文本格式的代码还是可执行的代码,都被称为程序程序是静态的安静地呆在磁盘上什么也干不了。要想让程序处理数据完成计算任务必须把程序从外部设备加载到内存中并在操作系统的管理调度下交给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层面锁是如何实现的

你不妨思考一下这个问题,把你的思考写在下面的评论区里,我会和你一起交流。也欢迎你把这篇文章分享给你的朋友或者同事,一起交流一下。