进程、线程与协程总结

计算机操作系统

Posted by 谢玄xx on April 22, 2022

进程与线程简介

进程

  • 进程是对运行时程序的封装,是系统进行资源调度和分配的的基本单位,实现了操作系统的并发;
  • 进程是操作系统进行资源分配和调度的基本单位,多个进程之间相互独立;
  • 进程稳定性好,如果一个进程崩溃,不影响其他进程,但是进程消耗资源大,开启的进程数量有限制

进程在使用资源前应申请资源,在使用资源之后应释放资源。一个进程可能要申请许多资源,以便完成指定任务。显然,申请的资源数量不能超过系统所有资源的总和。换言之,如果系统只有两台打印机,那么进程就不能申请三台打印机。

在正常操作模式下,进程只能按如下顺序使用资源:
申请:进程请求资源。如果申请不能立即被允许(例如,申请的资源正在被其他进程使用),那么申请进程应等待,直到它能获得该资源为止。
使用:进程对资源进行操作(例如,如果资源是打印机,那么进程就可以在打印机上打印了)。
释放:进程释放资源。

当进程或线程每次使用内核管理的资源时,操作系统会检查以确保该进程或线程已经请求并获得了资源。系统表记录每个资源是否是空闲的或分配的。对于每个已分配的资源,该表还记录了它被分配的进程。如果进程申请的资源正在为其他进程所使用,那么该进程会添加到该资源的等待队列上。

当一组进程内的每个进程都在等待一个事件,而这一事件只能由这一组进程的另一个进程引起,那么这组进程就处于死锁状态。这里所关心的主要事件是资源的获取和释放。资源可能是物理资源(例如,打印机、磁带驱动器、内存空间和 CPU 周期)或逻辑资源(例如,信号量、互斥锁和文件)。然而,其他类型的事件也会导致死锁(例如 IPC 功能)。
对于死锁的解释,请移步之后的博文——死锁现象与解决方法

线程

  • 线程是进程的子任务,是CPU调度和分派的基本单位,用于保证程序的实时性,实现进程内部的并发;
  • 线程是操作系统可识别的最小执行和调度单位。每个线程都独自占用一个虚拟处理器:独自的寄存器组,指令计数器和处理器状态。每个线程完成不同的任务,但是共享同一地址空间(也就是同样的动态内存,映射文件,目标代码等等),打开的文件队列和其他内核资源。
  • 如果IO操作密集,则可以多线程运行效率高,缺点是如果一个线程崩溃,都会造成进程的崩溃。
  • 程序里的主函数通常就是主线程。 再创造线程要调用创造线程的API。
  • Go语言的routine是一种轻量级的线程。
  • 一台服务器,如果用线程模型来扛,一台大概可以支持10w的并发。而腾讯的微信团队使用了libco(用C++写的),是目前最强的协程框架,一台服务器可以支持千万级别的并发连接;此外,还有微软的stackless库,这是C++20官方库。

线程安全

所谓的线程安全问题,其实就是指多个线程同时对于某个共享资源的访问,导致的原子性、可见性和有序性的问题。

先举一个线程不安全的例子:定义一个int型的count变量,然后开启两个线程,每个线程执行10000次for循环,循环中对count执行+1操作。等待两个线程都执行完成后,打印count的值。运行后发现这个值不是20000,而是比20000略小的一个数,且每次执行结果都不相同。这就是线程不安全导致的结果。

  • 线程安全指的是,在多线程环境下,程序可以始终执行正确的行为,符合预期的逻辑。
  • 如上面所述这种情况,预期逻辑是count被累加20000次,而实际上却没有,因此它就是线程不安全的。理由是:count++的指令不是原子性的(我们大致可以认为基本数据类型的访问读写是具备原子性的),而是要分为三步来进行。即先从内存中读取出count的值,然后执行+1操作,最后把结果写回内存中。当线程1“读取count的值为1”这个过程完成后,切换到线程2执行。线程2此时同样读取到了count值为1,然后进行后面的“改”和“写”操作,此时count的值变为2。这个时候,线程又切换回线程1,但线程1中count的值依然是线程2修改前的1。这是因为,虽然线程2修改了count的值,但这种修改动作对线程1是不可见的。这种“不可见”导致线程不安全。

如何保证线程安全?

可能导致线程不安全的原因有以下三点:

  1. 原子性。一个或者多个操作在CPU的执行过程中被中断。
  2. 可见性。一个线程对共享变量的修改(如前文的count),另外一个线程不能立即看到;
  3. 有序性。程序执行的顺序没有按照代码的顺序进行。为什么会不一致呢?Java有两种编译器——静态编译器和动态编译器。静态编译器把.java文件编译成.class文件,之后便可以解释执行;动态编译器是将.class文件编译成机器码,之后再由jvm运行。有时候,动态编译器为了程序的整体性,会对指令进行重排序。这种重排序虽然可以提升系统的性能,但重排序之后可能会导致源代码中指定的内存访问顺序和实际的执行顺序不一样,这就会导致线程不安全的隐患。

如何保证线程安全?

  1. 针对原子性问题,Java里面有很多atomic类,本身可以通过CAS来保证操作的原子性
  2. JAVA提供了各种锁机制,锁机制可以保证锁内的代码块在同一时刻只能有一个线程执行。这样就可以保证一个线程在对资源进行读、改、写操作时,其他线程不可以对这个资源进行操作,(锁住!)从而保证了线程安全。同步锁就行。
  3. 可以通过lock接口保证有序性,从而保证线程安全。

进程与线程的区别

  1. 一个线程只属于一个进程,而一个进程可以有多个线程(请注意,一个进程至少需要有一个进程,不能空)。线程依赖于进程而存在;
  2. 进程在执行过程中拥有独立的内存单元,而多个线程共享进程的内存。 (资源分配给进程,同一进程的所有线程共享该进程的所有资源。同一进程中的多个线程共享代码段(代码和常量),数据段(全局变量和静态变量),扩展段(堆存储)。但是每个线程拥有自己的栈段,栈段又叫运行时段,用来存放所有局部变量和临时变量。)
  3. 进程是资源分配的最小单位,线程是CPU调度的最小单位。
  4. 系统开销: 由于在创建或撤消进程时,系统都要为之分配或回收资源,如内存空间、I/O设备等。因此,操作系统所付出的开销将显著 > 在创建或撤消线程时的开销。类似地,在进行进程切换时,涉及到整个当前进程CPU环境的保存以及新被调度运行的进程的CPU环境的设置。而线程切换只须保存和设置少量寄存器的内容,并不涉及存储器管理方面的操作。可见,进程切换的开销也远大于线程切换的开销。
  5. 通信:由于同一进程中的多个线程具有相同的地址空间,致使它们之间的同步和通信的实现,也变得比较容易。进程间通信IPC,线程间可以直接读写进程数据段(如全局变量)来进行通信——需要进程同步和互斥手段的辅助,以保证数据的一致性。在有的系统中,线程的切换、同步和通信都无须操作系统内核的干预。
  6. 进程编程调试简单可靠性高,但是创建销毁开销大;线程正相反,开销小,切换速度快,但是编程调试相对复杂。
  7. 进程间不会相互影响 ;线程一个线程挂掉将导致整个进程挂掉。
  8. 进程适应于多核、多机分布;线程适用于多核。

什么是多进程?

  • 进程其实是资源的分配的单位,包括代码、内存、CPU等等,多进程类似程序的多开,比如qq的多开。
//强转c++,可能有误,理解意思即可
import multiprocessing
import time

void test1(){
    while(true)
    {
        cout << "--- this is process 1 ---" << endl;
        time.sleep(2);
    }
}
        
void test2(){
    while(true)
    {
        cout << "--- this is process 2 ---" << endl;
        time.sleep(2);
    }
}
int main():
    t1 = multiprocessing.Process(target=test1);
    t2 = multiprocessing.Process(target=test2);
    t1.start();
    t2.start();
    return 0;
  • 多进程工作的原理:在主进程下,子进程1和子进程2分别复制了主进程的代码以及资源,而子进程1则只运行test1这个函数,子进程2则只运行test2这个函数,进程之间的全局变量互不影响,对资源的开销比较大。
  • 打开任务管理器,结束任意子进程,发现主进程和子进程2没有受到影响,仍然继续运行,而当我们结束主进程的时候,所有子进程全部结束。

多进程的优点

  1. 每个进程互相独立,不影响主程序的稳定性,子进程崩溃没关系;
  2. 通过增加CPU,就很容易扩充性能;
  3. 可以尽量减少线程加锁/解锁的影响,极大提高性能,就算是线程运行的模块算法效率低也没关系;
  4. 每个子进程都有2GB地址空间和相关资源,总体能够达到的性能上限非常高。

多进程的缺点

  1. 逻辑控制复杂,需要和主程序交互;
  2. 需要跨进程边界,如果有大数据量传送,就不太好,适合小数据量传送、密集运算多进程调度开销比较大;
  3. 最好是多进程和多线程结合,即根据实际的需要,每个CPU开启一个子进程,这个子进程开启多线程可以为若干同类型的数据进行处理。当然你也可以利用多线程+多CPU+轮询方式来解决问题……
  4. 方法和手段是多样的,关键是自己看起来实现方便有能够满足要求,代价也合适。

什么是多线程?

  • 在一个进程中我们也可以使用多任务,这就是线程,线程是操作系统资源调度的单位。多线程可以共享全局变量
  • 多线程并不会复制主进程的代码和资源,而是共享全局变量。相比多进程来说,资源开销更加小。
  • 在同一时间,子线程会同时运行,实现多任务,而他们会共享全局变量。

多线程的优点

  1. 无需跨进程边界;
  2. 程序逻辑和控制方式简单;
  3. 所有线程可以直接共享内存和变量等;
  4. 线程方式消耗的总资源比进程方式少。

多线程的缺点

  1. 每个线程与主程序共用地址空间,受限于2GB地址空间;
  2. 线程之间的同步和加锁控制比较麻烦;
  3. 一个线程的崩溃可能影响到整个程序的稳定性;
  4. 到达一定的线程数程度后,即使再增加CPU也无法提高性能,例如Windows Server 2003,大约是1500个左右的线程数就快到极限了(线程堆栈设定为1M),如果设定线程堆栈为2M,还达不到1500个线程总数;
  5. 线程能够提高的总性能有限,而且线程多了之后,线程本身的调度也是一个麻烦事儿,需要消耗较多的CPU。

不同场景下,多进程/多线程该如何选择?

  1. 需要频繁创建销毁的优先用线程 这种原则最常见的应用就是Web服务器了,来一个连接建立一个线程,断了就销毁线程,要是用进程,创建和销毁的代价是很难承受的

  2. 需要进行大量计算的优先使用线程 所谓大量计算,当然就是要耗费很多CPU,切换频繁了,这种情况下线程是最合适的。 这种原则最常见的是图像处理、算法处理。

  3. 强相关的处理用线程,弱相关的处理用进程 什么叫强相关、弱相关?理论上很难定义,给个简单的例子就明白了: 一般的Server需要完成如下任务:消息收发、消息处理。“消息收发”和“消息处理”就是弱相关的任务,而“消息处理”里面可能又分为“消息解码”、“业务处理”,这两个任务相对来说相关性就要强多了。因此“消息收发”和“消息处理”可以分进程设计,“消息解码”、“业务处理”可以分线程设计。 当然这种划分方式不是一成不变的,也可以根据实际情况进行调整。

  4. 可能要扩展到多机分布的用进程,多核分布的用线程

  5. 都满足需求的情况下,用你最熟悉、最拿手的方式 至于“数据共享、同步”、“编程、调试”、“可靠性”这几个维度的所谓的“复杂、简单”应该怎么取舍,我只能说:没有明确的选择方法。但我可以告诉你一个选择原则:如果多进程和多线程都能够满足要求,那么选择你最熟悉、最拿手的那个。 需要提醒的是:虽然给了这么多的选择原则,但实际应用中基本上都是“进程+线程”的结合方式,千万不要真的陷入一种非此即彼的误区。

  6. 在Linux下编程多用多进程编程,少用多线程编程;

  7. 多线程比多进程性能高?误导!——应该说,多线程比多进程成本低,但性能更低。

多进程是立体交通系统,虽然造价高,上坡下坡多耗点油,但是不堵车; 多线程是平面交通系统,造价低,但红绿灯太多,老堵车。 我们现在都开跑车,油(主频)有的是,不怕上坡下坡,就怕堵车。

感谢原作者——雪的季节,参考文章: 多进程和多线程的区别是什么?


电脑在启动的过程中,都发生了什么?

线程池

之前有被问到过线程池的基本概念,故在此略作总结。

在线程池中存在几个概念:核心线程数、最大线程数、任务队列。

  • 核心线程数——线程池的基本大小;
  • 最大线程数——同一时刻线程池中线程的数量最大不能超过该值;
  • 任务队列——当任务较多时,线程池中线程的数量已经达到了核心线程数,这时候就是用任务队列来存储我们提交的任务。

传统多线程方案中,采用的服务器模型则是:一旦接受到请求之后,即创建一个新的线程,由该线程执行任务。任务执行完毕后,线程退出,这就是是“即时创建,即时销毁”的策略。尽管与创建进程相比,创建线程的时间已经大大的缩短,但是如果提交给线程的是执行时间较短而且执行次数极其频繁的任务,那么服务器将处于不停的创建线程 & 销毁线程的状态。我们将传统方案中的线程执行过程分为三个过程:T1、T2、T3。 T1:线程创建时间; T2:线程执行时间,包括线程的同步等时间; T3:线程销毁时间; 从上述线程执行过程我们可以看出,线程本身的开销所占的比例为(T1+T3) / (T1+T2+T3)。如果线程执行的时间很短的话,这比开销可能占到20%-50%左右。如果任务执行时间很长的话,这笔开销将是不可忽略的。

着眼于减少线程本身带来的开销,线程池的概念应运而生。线程池采用预创建的技术,在应用程序启动之后,将立即创建一定数量的线程(N1),放入空闲队列中。这些线程都是处于阻塞(Suspended)状态,不消耗CPU,但会占用较小的内存空间。当任务到来后,缓冲池选择一个空闲线程,把任务传入此线程中运行。当N1个线程都在处理任务后,缓冲池自动创建一定数量的新线程,用于处理更多的任务。在任务执行完毕后线程也不退出,而是继续保持在池中等待下一次的任务。当系统比较空闲时,大部分线程都一直处于暂停状态,线程池自动销毁一部分线程,回收系统资源。基于这种预创建技术,线程池将线程创建和销毁本身所带来的开销分摊到了各个具体的任务上,执行次数越多,每个任务所分担到的线程本身开销则越小,不过我们另外可能需要考虑进去线程之间同步所带来的开销。

协程

  • 协程又称绿色线程、纤程,是当前最火的技术之一。
  • 协程是另一种支持并发的方式,它通过自主调度来支持多线程并发。还有一种方式当然是多线程啦。
  • 当调用一个函数时,程序从函数的头部开始执行,当函数退出时,这个函数的声明周期也就结束了。一个函数在它的生命周期中,只可能返回一次。而协程则不同,协程在执行过程中,可以调用别的协程自己则中途退出执行,之后又从调用别的协程的地方恢复执行。这有点像操作系统的线程,执行过程中可能被挂起,让位于别的线程执行,稍后又从挂起的地方恢复执行。在这个过程中,协程与协程之间实际上不是普通“调用者与被调者”的关系,他们之间的关系是对称的。实际上,协程不一定都是这种对称的关系,还存在着一种非对称的协程模式(asymmetric coroutines)。非对称协程其实也比较常见,上文所述微信团队使用的libco其实就是一种非对称协程。

顺便提一句,Java实现调用C/C++函数(如JVM)的方式是:在函数名前面加关键词“native”。如果在Linux系统下,那就是使用了pthread_create()方法,通过Man手册完成。