线程那些事儿

目录

一、什么是程序? 

二、程序?进程?傻傻分不清 

1.程序是个文件,放在磁盘上

(1)编译器工作

2.程序在内存中运行起来之后就叫进程

三、操作系统是如何看待进程的

1.系统调用

2.操作系统是如何看待进程的

四、你需要理解同步与异步 

1.同步调用

2.异步调用

五、无门槛理解线程  

六、 高并发中的线程与线程池 

1.PC 寄存器

2.进程

3.线程

4.线程池

(1)短任务

(1)CPU密集型

(2)I/O密集型

七、线程间到底共享了哪些进程资源?  

1.C++程序运行时的内存模型

(1)栈区(共享,安全)

(2)堆区(共享)

(3)代码区(共享,安全)

(4)数据区(共享)

(5)动态链接库(共享,安全)

2.文件(共享)

3.线程局部存储,Thread Local Storage,TLS

八、线程安全代码到底是怎么编写的?  

1.识别线程的共享资源

2.需要注意线程安全的情况

3.如何实现线程安全?

九、高并发高性能服务器是如何实现的

1.多进程

2.多线程

3.事件驱动编程,event-based concurrency

4.用户态线程(协程)

十、程序员应如何理解高并发中的协程  

十一、进程切换与线程切换的区别? 

1.虚拟内存

2.进程切换与线程切换的主要区别

3.为什么虚拟地址切换很慢?

十二、上下文信息的保存与恢复 

1.寄存器快啊

2.上下文的作用

(1)函数调用

(2)系统调用

(3)中断处理

(4)线程切换

十三、 CPU 核数与线程数有什么关系?  

1.进程与线程

2.线程不是越多越好

3.总结

十四、一个耗时4小时的内存泄漏问题  

十五、使用多线程会加快文件读取速度吗?   


一、什么是程序? 

        程序这个词有两种含义:

  • 人类可以认识的程序,这些程序就是用比如C/C++,Java,Python语言写成的文本文件。比如helloworld.c,hellworld.java,helloworld.py
  • 机器可以认识的程序,这些程序就是可执行程序。Windows下就是exe程序,Linux下就是elf程序。

        程序分为编译型程序(比如C/C++)以及解释型程序(比如Java、Python、JavaScript等)。编译型程序被编译器直接翻译成CPU可以直接运行的机器指令,而解释型程序无需编译,其运行依靠的是解释器,解释器是一个可以执行程序的程序,解释器这个程序一般是由C/C++程序编写的。        

        需要注意的是操作系统也是一个程序,只不过这个程序的作用比较特殊,这个程序是用来管理计算机系统中各种软硬件资源的,比如提供进程、线程机制,管理CPU等等。

二、程序?进程?傻傻分不清 

        程序是一个静态的概念,进程是一个动态的概念。如果把菜谱比作我们写的程序,那么按照菜谱真正炒菜的这个过程才是进程。

1.程序是个文件,放在磁盘上

        程序其实包含两部分内容,一部分是指令(代码),另一部分是数据。比如 int a = 100; 这段代码在生成的可执行程序中是没有对应的机器指令的,为什么,因为这是数据。那么什么样的代码才有对应的可执行程序呢?比如if... while... +-*/,return等语句才会有对应的机器指令。

(1)编译器工作

        编译器就是将“一种语言(通常为高级语言)”翻译为“另一种语言(通常为低级 语言 )”的程序。

        编译器的工作就是把C程序中的对数据的操作部分翻译成二进制机器指令,这些指令统一放在二进制文件中的一部分,这一部分就叫代码段,然后编译器收集C程序中定义的数据,把这些数据统一放在二进制文件中的另一部分,这一部分就叫数据段,就好比披萨一样分为两层,一个可执行文件就如下图所示:

         总结:编译器将源代码分成两类,一类是对数据的操作,这一部分就被编译器翻译成了机器指令;另一类是数据,这些数据被编译器收集后放到了可执行文件的数据段。

2.程序在内存中运行起来之后就叫进程

Q:程序是如何被运行起来的呢?

A:我们的程序实际上是被操作系统运行起来的,简单来说,大体经过了以下几个阶段:

  • 当我们双击程序图标或者键入程序名字后,操作系统根据程序的名字去磁盘中找到可执行程序。
  • 操作系统在内存为即将要运行的程序划出一块区域。
  • 操作系统将找到的可执行程序从磁盘中copy到刚刚划分出的内存区域当中。
  • 操作系统在内存中找到可执行程序代码段的起始位置,假设这个地址是A。
  • 操作系统告诉CPU从A这个位置开始执行。

三、操作系统是如何看待进程的

include <stdio.h>

int main(){
    printf("Hello World."); //这里需要操作系统的帮助
    return 0;
}

        当执行printf();这句时话时我们的程序把参数“Hello World.”这几个字符告诉操作系统,之后执行的就不再是我们的程序了,而是操作系统,操作系统拿到字符串“Hello World.”后把这几个字符输出到屏幕上,操作系统完成这一任务后把控制权又交给我们的程序。

        当你的程序在运行时,CPU不是一直在执行你的程序的,而是要时不时的跳转到操作系统,通过运行操作系统来完成程序中某些函数调用。

1.系统调用

        上例中,printf真正调用的是一个叫做write的系统调用。

        系统调用是用户程序和操作系统通信的媒介。用户程序向操作系统发起请求实际上是通过系统调用来完成的,因此我们可以说:系统调用是用户程序与操作系统的信使。也就是说,我们的程序是通过系统调用来向操作系统发起请求的。

        操作系统就是一个大的C程序,本质上和我们的程序没有任何区别,当CPU开始执行我们的代码时就表现为进程在运行,当CPU执行操作系统的代码时就表现为操作系统在运行。

        当我们的程序在访问硬件,比如读写文件(磁盘)、收发网络数据(网卡)、接收键盘按键、响应鼠标点击事件、又或者创建线程、创建进程、查看系统时间等操作时都需要依赖操作系统完成这些任务。到目前为止,需要记住两点:

  1. 当我们调用涉及到比如硬件的函数时,实际上是操作系统替我们完成的,这个过程叫系统调用。
  2. 系统调用和普通的函数调用是不同的。

2.操作系统是如何看待进程的

        操作系统不信任应用程序,操作系统只信任自己。因此操作系统需要对用户程序进行严加防范,那么操作系统该如何做到这一点呢,这就涉及到了“控制与被控制”的问题,在这里被控制的一方是用户程序,而施加控制的一方是操作系统,同时“控制与被控制”是单向的,用户程序不能翻过来控制操作系统。

你需要理解同步与异步 

1.同步调用

        同步调用和函数与被调函数是否运行在同一个线程是没有关系的。举个栗子:阻塞式I/O,在read函数返回前程序是无法继续向前推进的。

2.异步调用

        两件事在同时进行而不是一方等待另一方因此这就是为什么一般来说异步比同步高效的本质所在。一般来说,异步调用总是和I/O操作等耗时较高的任务如影随形,像磁盘文件读写、网络数据的收发、数据库操作等。

        调用方需要知道执行结果的两种方法:

  1. 一种是通知机制,也就是说当任务执行完成后发送信号来通知调用方任务完成,注意这里的信号有很多实现方式,Linux中的signal,或者使用信号量等机制都可以实现。
  2. 另一种是就是回调,也就是我们常说的callback。

五、无门槛理解线程  

        用一个简单的例子来说明一下,看电影,媒体播放器至少有这样几个线程:一个线程用来渲染UI,可以让让用户暂停、快进等等;还有一个播放声音以及画面的线程。

        假设系统中有两个线程A和B:

  • 并行操作指的是A和B在同时运行,也就是同一时刻有两个线程在运行。
  • 并发是指同一时刻只有一个线程在运行,但是A运行一会后就停止然后运行B,这样看起来像是A和B在同时运行,但这不是真正的并行。

        作为程序员是不能控制线程是以怎样的顺序来执行的,实际上即使像这样简单的两句程序,程序员都无法控制这两个线程的启动顺序。

threadA.start();
threadB.start();

六、 高并发中的线程与线程池 

        CPU 并不知道线程、进程之类的概念。CPU 只知道两件事:

  1. 从指令寄存器(Program Counter),即程序计数器中取出指令。
  2. 执行指令,然后回到 1

1.PC 寄存器

        PC 寄存器中存放的是什么呢?这里存放的是指令在内存中的地址,什么指令呢?是 CPU 将要执行的下一条指令。当每个 指令 被获取,程序计数器的存储地址加一。CPU 执行一个函数,那么只需要把该函数对应的第一条机器指令的地址写入 PC 寄存器就可以了,这样我们写的函数就开始被 CPU 执行起来啦。

        编译器从我们的源代码中生成可执行程序,可执行程序是由一系列机器指令构成的,可执行程序在磁盘中,寄存器中的指令是从可执行程序中加载过来的。

2.进程

让 CPU 执行程序,我们需要两个步骤:

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

        我们会通过一个程序来完成上述两个步骤,机器指令需要加载到内存中执行,因此需要记录下内存的起始地址和长度;同时要找到函数的入口地址并写到 PC 寄存器中,我们用一个数据结构来记录下这些信息:

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

        进程:数据结构记录的是程序在被加载到内存中的运行状态,程序从磁盘加载到内存跑起来叫什么好呢?干脆就叫进程(Process)好了。

        main 函数:CPU 执行的第一个函数也起个名字,第一个要被执行的函数听起来比较重要,干脆就叫 main 函数吧。

        完成上述两个步骤的程序也要起个名字,就叫操作系统。

官方解释:

  • 进程:一段程序的执行过程。 进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
  • 操作系统: 操作系统(operating system,简称OS)是管理计算机硬件软件资源的 计算机程序 。操作系统需要处理如管理与配置内存决定系统资源供需的优先次序、控制 输入设备输出设备、操作网络与管理文件系统等基本事务。操作系统也提供一个让用户与系统 交互的操作界面。

        进程是需要占用内存空间的,这段内存区域中保存了CPU执行的机器指令以及函数运行时的堆栈信息,要想让进程运行,就把main函数的第一条机器指令地址写入PC寄存器,这样进程就运行起来了。

3.线程

        进程的缺点在于只有一个入口函数,也就是main函数,因此进程中的机器指令只能被一个CPU执行。我们可以把PC寄存器指向main函数,就可以把PC寄存器指向任何一个函数。当我们把PC寄存器指向非main函数时,线程就诞生了。此时,一个进程内可以有多个入口函数,也就是说属于同一个进程中的机器指令可以被多个CPU同时执行。

        创建线程要比创建进程快的一部分原因:因为线程是运行在所处进程的地址空间的,这块地址空间在程序启动时已经创建完毕,同时线程是程序在运行期间创建的(进程启动后),因此当线程开始运行的时候这块地址空间就已经存在了,线程可以直接使用。

        要注意线程间通信存在的问题(需要注意互斥),因为CPU执行指令时根本没有线程的概念。

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

        操作系统要为每个线程在进程的地址空间中分配一个栈,即每个线程都有独属于自己的栈

4.线程池

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

(1)短任务

        eg:服务请求。这种场景有两个特点:一个是任务处理所需时间短;另一个是任务数量巨大。

  1. 线程是操作系统中的概念(这里不讨论用户态线程实现、协程之类),因此创建线程天然需要借助操作系统来完成,操作系统创建和销毁线程是需要消耗时间的。
  2. 每个线程需要有自己独立的栈,因此当创建大量线程时会消耗过多的内存等系统资源。

        所以我们需要线程池。

        线程池如何工作?数据结构中的队列天然适合这种场景,提交任务的就是生产者,消费任务的线程就是消费者,实际上这就是经典的生产者-消费者问题。

        线程池线程个数:线程池的线程过少就不能充分利用CPU,线程创建的过多反而会造成系统性能下降,内存占用过多,线程切换造成的消耗等等。


        从处理任务所需要的资源角度看,线程分为两种类型:CPU密集型和I/O密集型。

(1)CPU密集型

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

(2)I/O密集型

        这一类任务可能计算部分所占用时间不多,大部分时间都用在了比如磁盘I/O、网络I/O等。

        这种情况下就稍微复杂一些了,你需要利用性能测试工具评估出用在I/O等待上的时间,这里记为WT(wait time),以及CPU计算所需要的时间,这里记为CT(computing time),那么对于一个N核的系统,合适的线程数大概是N * (1 + WT/CT),假设I/O等待时间和计算时间相同,那么你大概需要2N个线程才能充分利用CPU资源,注意这只是一个理论值,具体设置多少需要根据真实的业务场景进行测试。

        如果线程池中的任务有I/O操作,那么务必对此任务设置超时,否则处理该任务的线程可能会一直阻塞下去。

七、线程间到底共享了哪些进程资源?  

1.C++程序运行时的内存模型

        每个线程都有自己独立的栈区。所属线程的栈区、程序计数器、栈指针以及函数运行使用的寄存器是线程私有的。这些信息有一个统一的名字,就是线程上下文,thread context。

        线程共享进程地址空间中除线程上下文信息中的所有内容,即下图中的内容。

        代码区和动态链接库这两个区域是不能被修改的,也就是说这两个区域是只读的,因此多个线程使用是没有问题的。

(1)栈区(共享,安全)

        一般来说栈区时安全的,因为存放的是线程里的局部变量。但是,栈区属于线程私有这一规则并没有严格遵守。

        线程的栈区没有严格的隔离机制来保护,因此如果一个线程能拿到来自另一个线程栈帧上的指针,那么该线程就可以改变另一个线程的栈区,也就是说这些线程可以任意修改本属于另一个线程栈区中的变量。

(2)堆区(共享)

        C/C++中用malloc或者new出来的数据就存放在这个区域,很显然,只要知道变量的地址,也就是指针,任何一个线程都可以访问指针指向的数据,因此堆区也是线程共享的属于进程的资源。

(3)代码区(共享,安全)

        这里保存的是我们写的代码,更准确的是编译后的可执行机器指令

        线程之间共享代码区,这就意味着程序中的任何一个函数都可以放到线程中去执行,不存在某个函数只能被特定线程执行的情况

(4)数据区(共享)

        进程地址空间中的数据区,这里存放的就是所谓的全局变量,即函数之外定义的变量。

        在程序员运行期间,也就是run time,数据区中的全局变量有且仅有一个实例,所有的线程都可以访问到该全局变量

        值得注意的是,在C语言中还有一类特殊的“全局变量”,那就是用static关键词修饰过的变量。

void func(){
    static int a = 10;
}

        虽然变量a定义在函数内部,但变量a依然具有全局变量的特性,也就是说变量a放在了进程地址空间的数据区域,即使函数执行完后该变量依然存在。

(5)动态链接库(共享,安全)

        可执行程序:在Windows中就是我们熟悉的exe文件,在Linux世界中就是ELF文件,这些可以被操作系统直接运行的程序就是我们所说的可执行程序。

        编译器在将可执行程序翻译成机器指令后,接下来还有一个重要的步骤,这就是链接,链接完成后生成的才是可执行程序。

         其中链接器可以有两种链接方式,这就是静态链接动态链接

        静态链接的意思是说把所有的机器指令一股脑全部打包到可执行程序中,动态链接的意思是我们不把动态链接的部分打包到可执行程序,而是在可执行程序运行起来后去内存中找动态链接的那部分代码,这就是所谓的静态链接和动态链接。

        动态链接一个显而易见的好处就是可执行程序的大小会很小,就像我们在Windows下看一个exe文件可能很小,那么该exe很可能是动态链接的方式生成的。动态链接的部分生成的库就是我们熟悉的动态链接库,在Windows下是以DLL结尾的文件,在Linux下是以so结尾的文件。

        原来如果一个程序是动态链接生成的,那么其地址空间中有一部分包含的就是动态链接库,否则程序就运行不起来了,这一部分的地址空间也是被所有线程所共享的。

2.文件(共享)

        如果程序在运行过程中打开了一些文件,那么进程地址空间中还保存有打开的文件信息,进程打开的文件也可以被所有的线程使用,这也属于线程间的共享资源。

3.线程局部存储,Thread Local Storage,TLS

        线程局部存储可以让你使用一个独属于线程的全局变量。也就是说,虽然该变量可以被所有线程访问,但该变量在每个线程中都有一个副本,一个线程对改变量的修改不会影响到其它线程。

__thread int a = 1; // 全局变量a前面加了一个__thread关键词用来修饰,也就是说我们告诉编译器把变量a放在线程局部存储中

void print_a() {
    cout<<a<<endl;
}

void run() {
    ++a;
    print_a();
}

void main() {
    thread t1(run);//2
    t1.join();

    thread t2(run);//2
    t2.join();
}

八、线程安全代码到底是怎么编写的?  

  • 线程私有的资源,没有线程安全问题
  • 线程共享的资源,线程间以某种秩序使用共享资源也能实现线程安全。

1.识别线程的共享资源

        识别线程的私有资源和共享资源都有哪些,这是解决线程安全问题的核心所在。

        既然线程运行的本质就是函数的执行,那么函数运行时信息都保存在哪里呢

        答案就是栈区,每个线程都有一个私有的栈区,因此在栈上分配的局部变量就是线程私有的,无论我们怎样使用这些局部变量都不管其它线程屁事。

        堆区、数据区以及文件,这些就是所有的线程都可以共享的资源,也就是公共场所,线程在这些公共场所就不能随便浪了。

         线程使用这些共享资源必须要遵守秩序,这个秩序的核心就是对共享资源的使用不能妨碍到其它线程,无论你使用各种锁也好、信号量也罢,其目的都是在维护公共场所的秩序。

2.需要注意线程安全的情况

  1. 传入的参数是在堆上(heap)用malloc或new出来的;
  2. 传入的参数是以&形式传入的全局变量。
  3. 修改全局变量。

3.如何实现线程安全?

  • 不使用任何全局资源,只使用线程私有资源,这种通常被称为无状态代码
  • 线程局部存储,如果要使用全局资源,是否可以声明为线程局部存储,因为这种变量虽然是全局的,但每个线程都有一个属于自己的副本,对其修改不会影响到其它线程
  • 只读,如果必须使用全局资源,那么全局资源是否可以是只读的,多线程使用只读的全局资源不会有线程安全问题。
  • 原子操作,原子操作是说其在执行过程中是不可能被其它线程打断的,像C++中的std::atomic修饰过的变量,对这类变量的操作无需传统的加锁保护,因为C++会确保在变量的修改过程中不会被打断。我们常说的各种无锁数据结构通常是在这类原子操作的基础上构建的
  • 同步互斥,到这里也就确定了你必须要以某种形式使用全局资源,那么在这种情况下公共场所的秩序必须得到维护,那么怎么维护呢?通过同步或者互斥的方式,这是一大类问题,我们将在《深入理解操作系统》系列文章中详细阐述这一问题。

九、高并发高性能服务器是如何实现的

1.多进程

        为每个请求创建一个进程:进程间通信需要依靠IPC,而且性能也是一大问题。

2.多线程

        由于线程共享进程地址空间,因此线程间通信天然不需要借助任何通信机制,直接读取内存就好了。

        为每个请求创建一个线程:虽然线程创建开销相比进程小,但依然也是有开销的,对于动辄数万数十万的链接的高并发服务器来说,创建数万个线程会有性能问题,这包括内存占用、线程间切换,也就是调度的开销。

3.事件驱动编程,event-based concurrency

        Event loop中要做的事情其实是非常简单的,只需要等待event的带来,然后调用相应的event处理函数即可。

while(true) {
    event = getEvent();
    handler(event);
}

        注意,这段代码只需要运行在一个线程或者进程中,只需要这一个event loop就可以同时处理多个用户请求。原因在于:

        对于web服务器来说,处理一个用户请求时大部分时间其实都用在了I/O操作上,像数据库读写、文件读写、网络读写等。当一个请求到来,简单处理之后可能就需要查询数据库等I/O操作,我们知道I/O是非常慢的,当发起I/O后我们大可以不用等待该I/O操作完成就可以继续处理接下来的用户请求。

        这个事件也就是event该怎么获取?IO多路复用技术正是用来解决这一问题的,通过IO多路复用技术,我们一次可以监控多个文件描述,当某个文件(socket)可读或者可写的时候我们就能得到通知啦。这样IO多路复用技术就成了event loop的原材料供应商,源源不断的给我们提供各种event,这样关于event来源的问题就解决了。

4.用户态线程(协程)

  •         同步IO:阻塞
  •         异步IO:基于事件编程。
  •         用户态线程:user level thread,也就是大名鼎鼎的协程

        虽然基于事件编程有这样那样的缺点,但是在当今的高性能高并发服务器上基于事件编程方式依然非常流行,但已经不是纯粹的基于单一线程的事件驱动了,而是event loop + multi thread + user level thread。

十、程序员应如何理解高并发中的协程  

        和普通函数只有一个返回点不同,协程(yield)可以有多个返回点。

        协程之所以神奇就神奇在当我们从协程返回后还能继续调用该协程,并且是从该协程的上一个返回点后继续执行。当普通函数返回后,进程的地址空间中不会再保存该函数运行时的任何信息,而协程返回后,函数的运行时信息是需要保存下来的。

        函数运行时所有的状态信息都位于函数运行时栈中。函数运行时栈就是我们需要保存的状态,也就是所谓的上下文。既然函数的运行时状态保存在栈区的栈帧中,那么如果我们想暂停协程的运行就必须保存整个栈帧的数据,那么我们该将整个栈帧中的数据保存在哪里呢?答案是堆区。

        我们需要做的就是在堆区中申请一段空间,让后把协程的整个栈区保存下,当需要恢复协程的运行时再从堆区中copy出来恢复函数运行时状态。实际上,我们需要做的是直接把协程的运行需要的栈帧空间直接开辟在堆区中,这样都不用来回copy数据了。

        使用协程理论上我们可以开启无数并发执行流,只要堆区空间足够,同时还没有创建线程的开销,所有协程的调度、切换都发生在用户态,这就是为什么协程也被称作用户态线程的原因所在。

十一、进程切换与线程切换的区别? 

        每个进程都有自己的虚拟地址空间,进程内的所有线程共享进程的虚拟地址空间。

1.虚拟内存

        虚拟内存是操作系统为每个进程提供的一种抽象,每个进程都有属于自己的、私有的、地址连续的虚拟内存,当然我们知道最终进程的数据及代码必然要放到物理内存上,页表中记录了虚拟内存地址到物理内存地址的映射关系。有了页表就可以将虚拟地址转换为物理内存地址了。

2.进程切换与线程切换的主要区别

        进程切换与线程切换的一个最主要区别就在于进程切换涉及到虚拟地址空间的切换而线程切换则不会。因为每个进程都有自己的虚拟地址空间,而线程是共享所在进程的虚拟地址空间的,因此同一个进程中的线程进行线程切换时不涉及虚拟地址空间的转换。

3.为什么虚拟地址切换很慢?

        进程都有自己的虚拟地址空间,把虚拟地址转换为物理地址需要查找页表,页表查找是一个很慢的过程,因此通常使用Cache来缓存常用的地址映射,这样可以加速页表查找,这个cache就是TLB,Translation Lookaside Buffer,我们不需要关心这个名字只需要知道TLB本质上就是一个cache,是用来加速页表查找的。由于每个进程都有自己的虚拟地址空间,那么显然每个进程都有自己的页表,那么当进程切换后页表也要进行切换,页表切换后TLB就失效了,cache失效导致命中率降低,那么虚拟地址转换为物理地址就会变慢,表现出来的就是程序运行会变慢,而线程切换则不会导致TLB失效,因为线程线程无需切换地址空间,因此我们通常说线程切换要比较进程切换块,原因就在这里。

TLB是Translation Lookaside Buffer的简称,可翻译为“地址转换后援缓冲器”,也可简称为“快表”。简单地说,TLB就是页表的Cache,其中存储了当前最可能被访问到的页表项,其内容是部分页表项的一个副本。只有在TLB无法完成地址翻译任务时,才会到内存中查询页表,这样就减少了页表查询导致的处理器性能下降。

十二、上下文信息的保存与恢复 

1.寄存器快啊

        函数调用能让程序员提高代码可复用性,系统调用能让程序员向操作系统发起请求,进程线程切换让多任务成为可能,中断处理能让操作系统管理外部设备。

        实际上寄存器和内存没有什么本质的区别,都是用来存储信息的。但是,通常CPU可以在一个时钟周期内访问一次寄存器,CPU访问内存的速度大概要比访问寄存器慢100倍左右。因此如果CPU没有寄存器而完全依赖内存的话,那么计算速度将比现在慢的多。

        作为程序员来说,当我们使用高级语言编写的程序时,其操作的数据都存放在内存中,而对于负责运算类的机器指令来说其操作的数据都存放在寄存器中

2.上下文的作用

        通过这些寄存器(PC、栈指针、状态寄存器),你可以知道程序运行到当前这一刻时最细粒度的切面,这一时刻这些寄存器中保存的所有信息就是我们通常所说的上下文,context。

        上下文的作用是什么呢?只要你能拿到一个程序运行时的上下文并保存起来,那么你可以随时暂停该程序的运行,也可以随时利用该信息恢复该程序的运行。

        程序在运行过程中逃不出函数调用、系统调用、进程切换、线程切换以及中断处理这几项操作,由此可见上下文信息的保存和恢复在计算机科学中重要的作用。

(1)函数调用

        运行时栈+上下文让我们实现了函数调用。

        函数调用的难点在于CPU不能在平铺直叙的往前依次顺序的执行机器指令,而是要跳转到被调函数的第一条机器指令,执行完该函数后还要跳转回来。这里就涉及到函数的状态保存与状态恢复

        函数的运行时状态有什么呢?主要有返回地址以及使用的寄存器信息,这就是在本文开头讲解的寄存器,我们将其称为函数运行时上下文,简称为context。这块用来保存context的空间就是栈帧,当然这里不止保存上下文信息,还保存有函数参数,局部变量等信息。

(2)系统调用

        当你调用类似open这样的函数时,其实是操作系统在帮你完成文件打开操作,用户程序向操作系统请求服务就是通过系统调用实现的。

        操作系统内部肯定也是调用一系列函数来完成请求处理,有函数调用就需要运行时栈,那么操作系统完成系统调用所需要的运行时栈在哪里呢?答案就在内核栈中,Kernel Stack。

        每一个用户态线程在内核态都有一个对应的内核栈。


系统调用过程

        程序运行在用户态,此时内核栈还是空的,假设用户态执行到functionD时需要请求操作系统服务,假设functionD需要调用open函数,该函数内部包含就系统调用,被编译器翻译后会生成一条int指令,此时CPU执行到该指令:

        该指令的执行将触发CPU的状态切换,此时CPU从用户态切换为内核态,并找到该用户态线程对应的内核线程,注意重点来了,此时用户态线程的执行上下文信息(寄存器信息)被保存在内核栈中。

        此后CPU开始在内核中执行open相关的操作,后续内核栈会像用户态运行时栈一样随着函数的调用和返回增长以及减少:

        当系统调用执行完成后,根据内核栈中保存的用户态程序上下文信息恢复CPU状态,并从内核态切换回用户态,这样用户态线程就可以继续运行了。

(3)中断处理

        计算机之所以能接受键盘按键、鼠标指针、网络数据等,都是通过中断机制来完成的。

        中断本质上就是打断当前CPU的执行流,跳转到具体的中断处理函数中,当中断处理函数执行完成后再跳转回来。

        既然中断处理函数也是函数,那么必然和普通函数一样需要运行时栈,那么中断处理函数的运行时栈又在哪里呢?这分为两种情况:

  • 中断处理函数是没有自己特定的栈的,中断处理函数依赖内核栈来完成中断处理。
  • 中断处理函数有自己特定的栈,被称之为ISR栈,ISR是interrupt service routine的简写,即中断处理函数栈。由于处理中断的是CPU,因此在这种方案下每个CPU都有一个自己的中断处理栈。

内核栈完成中断处理

        中断处理函数和系统调用比较类似,不同的是系统调用是用户态程序主动发起的,而中断处理是外部设备发起的,也就是说CPU在执行完用户态的任何一条指令后都可能因为中断产生而暂停当前程序的执行转而去执行中断处理函数。

  1. CPU从用户态切换为内核态,并找到该用户态线程对应的内核线程,并将用户态线程的执行上下文信息保存在内核栈中。
  2. 此后CPU跳转到中断处理函数起始地址,中断处理函数在运行过程中内核栈会像用户态运行时栈一样随着函数的调用和返回增长以及减少。
  3. 当中断处理函数执行完成后,根据内核栈中保存的用户态程序上下文信息恢复CPU状态,并从内核态切换回用户态,这样用户态线程就可以继续运行了。

(4)线程切换

        CPU执行线程的时候是通过时间分片的方式来轮流执行的,当某一个线程的时间片用完(到期),那么这个线程就会被中断,CPU不再执行当前线程,CPU会把使用权给其它线程来执行。如T1线程未执行结束,T2/T3线程插进来执行了,若干时间后T1又继续执行未执行完的部分,这种就造成了线程之间的来回切换。

        一次上下文切换:CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再次加载这个任务的状态,从任务保存到再加载的过程就是一次上下文切换。当Context Switch发生时,需要由操作系统保持当前线程的状态,并恢复另一个线程的状态,状态包括程序计数器、虚拟机栈中每个栈帧的信息。

造成线程切换的原因

  • 线程的CPU时间片用完
  • 垃圾回收
  • 有更高优先级的线程需要运行
  • 线程自已调用了sleep、yield、wait、park、synchronized、lock等方法

        既然上下文切换会导致额外的开销,因此减少上下文切换次数便可以提高多线程程序的运行效率。减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。

  • 无锁并发编程。多线程竞争时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash取模分段,不同的线程处理不同段的数据
  • CAS算法。Java的Atomic包使用CAS算法来更新数据,而不需要加锁
  • 使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态
  • 协程。在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换

十三、 CPU 核数与线程数有什么关系?  

        CPU的核心数和线程个数没有什么必然的关系。单个核心上可以跑任意多个线程,只要你的内存够就行;计算机系统内也可以有任意多核数,只要你有钱就行。

1.进程与线程

        CPU不知道执行的某一段机器指令属于A任务还是B任务,只有操作系统知道,同时操作系统还能知道任务A和B任务是否属于同一个地址空间

        如果属于同一个地址空间,那么任务A和任务B就是我们熟悉的“多线程”;如果不属于同一个地址空间,那么任务A和任务B就是我们熟悉的“多进程”,现在你应该明白这两个概念了吧。

2.线程不是越多越好

        单核情况下,如果一个任务中没有阻塞现象,那线程数开再多也没用,任务得一个一个来。如果有阻塞情况,那多线程可以节约时间。 

        如果想充分利用多核,那么这时你的确需要知道系统内有多少核数,一般来说你创建的线程数需要与核数保持线性关系。

        如果你的线程是不涉及任何I/O、没有任何同步互斥之类的纯计算类型,那么每个核心一个线程通常是最佳选择。但通常来说,线程都需要一定的I/O,可能需要一定的同步互斥,那么这时适当增加线程可能会提高性能,但当线程数量到达一个临界值后性能开始下降,这时线程间切换的开销将显著增加。

3.总结

        线程数和CPU核心数可以没有任何关联,如果在使用线程时仅仅针对上述提到的几个简单场景,那么你根本不需要关心CPU是单核还是多核。

        但当你需要利用线程充分发挥多核威力时,通常情况下你创建的线程数与核数要保持一种线性关系,最佳系数通常需要测试才能得到。

十四、一个耗时4小时的内存泄漏问题  

        多线程问题的关键——共享数据

        如果线程之间没有共享数据那么就不会有线程安全问题,我们使用的锁、信号量、条件变量等其实都是用来保护共享数据的,比如锁通常是用来包括临界区的,临界区中的代码操作的就是线程共享数据;信号量使用的一个经典场景就是生产者消费者问题,生产者线程以及消费者线程都会操作同一个队列,这里的队列就是共享数据。

        解决线程安全问题首先要考虑的不是要不要加锁,而是多个线程是否真的有必要使用共享数据,没有必要的话多个线程操作私有数据根本就不会出现线程安全问题。

十五、使用多线程会加快文件读取速度吗?  

        磁盘只有一个,所以使用多线程读取文件不会加快文件读取速度,甚至可能会使得情况更加糟糕。

        磁盘的顺序读取才是最快速的,这就是为什么我们把2G电影copy到磁盘的速度要远远快于copy 2G的多个文件的原因,因为一个电影文件作为整体可能顺序的放在磁盘当中,而多个文件会散落在磁盘的各个角落中。

        因此比较好的办法就是使用一个线程来顺序读取一个大文件,要想加快数据处理速度可以等文件读取完成后使用多个线程来处理这些已经加载到内存中的数据。

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
在Java中,多线程是一个重要的概念和特性。以下是Java多线程的一些重点: 1. 线程的创建和启动:可以通过继承Thread类或实现Runnable接口来创建线程,并使用start()方法启动线程。 2. 线程同步:在多线程环境下,可能会出现竞态条件(Race Condition)和资源争用问题。为了避免这些问题,可以使用关键字synchronized或使用Lock接口进行线程同步。 3. 线程安全:当多个线程同时访问共享数据时,可能会出现数据不一致的问题。为了确保线程安全,可以使用同步机制来保护共享数据。 4. 线程间通信:多个线程之间需要进行协调和通信,可以使用wait()、notify()和notifyAll()等方法来实现线程间的通信。 5. 线程池:线程池可以管理和复用线程,提高线程的利用率和性能。可以使用Executor框架或ThreadPoolExecutor类来创建和管理线程池。 6. 线程调度:线程调度是指操作系统对线程的调度和分配CPU时间的过程。Java提供了一些API来控制线程的优先级、休眠和唤醒等操作。 7. 并发工具类:Java提供了一些并发工具类,如CountDownLatch、CyclicBarrier、Semaphore和BlockingQueue等,用于在多线程环境下实现线程间的协作和同步。 8. 线程安全的集合类:Java提供了线程安全的集合类,如ConcurrentHashMap和CopyOnWriteArrayList等,用于在多线程环境下安全地操作集合。 以上是Java多线程的一些重点,掌握这些概念和技术可以帮助您更好地理解和应用多线程编程。同时,还需要注意处理线程间的竞争条件和共享资源的安全性,以避免潜在的问题。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

烫青菜

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值