【进程、线程、协程】进程与线程的区别(一)

本文深入探讨了进程和线程的基本概念,详细讲解了进程与线程的区别,包括创建、切换、运行特性和通信方式。同时,介绍了线程模型、线程同步机制,并对比了多线程与多进程的应用场景。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一、进程

1、从硬件看进程和线程两种模型

为了说明这个进程和线程的区别,我们先从硬件开始说为什么创建进程和线程这两个概念。

按内存划分计算机类型
如果我们把计算机系统按memory(内存)结构分类的话,可以分为shared-memory和distributed-memory:
在这里插入图片描述
分布式内存的例子就是分布式系统,每台机器有独立的内存。共享内存的例子就是我们的单机系统。

为了在共享内存中编程,计算机科学家发明了线程的概念,线程之间共享一些内存。

如图左边为单线程,右边为多线程,其中code、data、files是所有线程共享的,每个线程有独立的寄存器和栈空间:
在这里插入图片描述

为了适配两种硬件结构,计算机科学家于是构建出进程和线程的模型,分布式内存对应的是进程,共享内存对应的是线程

2、进程与线程的区别

所以进程和线程的本质区别如下:
线程是执行的单位,进程是资源的单位(CPU和文件)。同一进程下的线程之间共享数据。

所以线程和进程在创建、切换、运行之间有下列不同的特性:

  • 创建:线程创建更快。进程创建需要重新开辟空间,线程直接创建,开辟少量资源即可。

  • 切换:线程更快,进程切换需要涉及大量资源的切换,线程共享资源,切换速度更快

  • 运行:进程运行更稳定,一个线程的崩溃,会导致整个进程的崩溃。

  • 通信:进程间通信比较繁琐。

    进程创建的时候需要告诉操作系统开辟一块新的内存空间,速度比较慢,线程创建的时候只需要复制少量的资源。同理切换的速度也是线程比较快。根据这个可以得出下面提到的线程和进程适用的场景。

线程是告诉操作系统执行一条任务代码(线程的创建速度是进程的100倍)

进程的创建过程:
linux中通过fork系统调用

3、进程间的通信

1)管道(Pipe):管道可用于具有亲缘关系进程间的通信,允许一个进程和另一个与它有共同祖先的进程之间进行通信。

(2)命名管道(named pipe):命名管道克服了管道没有名字的限制,因此,除具有管道所具有的功能外,它还允许无亲缘关系进程间的通信。命名管道在文件系统中有对应的文件名。命名管道通过命令mkfifo或系统调用mkfifo来创建。

(3)信号(Signal):信号是比较复杂的通信方式,用于通知接受进程有某种事件发生,除了用于进程间通信外,进程还可以发送信号给进程本身;linux除了支持Unix早期信号语义函数sigal外,还支持语义符合Posix.1标准的信号函数sigaction(实际上,该函数是基于BSD的,BSD为了实现可靠信号机制,又能够统一对外接口,用sigaction函数重新实现了signal函数)。

(4)消息(Message)队列:消息队列是消息的链接表,包括Posix消息队列system V消息队列。有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺

(5)共享内存:使得多个进程可以访问同一块内存空间,是最快的可用IPC形式。是针对其他通信机制运行效率较低而设计的。往往与其它通信机制,如信号量结合使用,来达到进程间的同步及互斥。

(6)信号量(semaphore):主要作为进程间以及同一进程不同线程之间的同步手段。

(7)套接字(Socket):更为一般的进程间通信机制,可用于不同机器之间的进程间通信。起初是由Unix系统的BSD分支开发出来的,但现在一般可以移植到其它类Unix系统上:Linux和System V的变种都支持套接字。

3、解释孤儿进程,僵死进程,惊群效应

孤儿进程:
如果父进程先退出,子进程还没退出那么子进程将被 托孤给init进程,这是子进程的父进程就是init进程(1号进程).其实还是很好理解的.

僵尸进程:
如果我们了解过linux进程状态及转换关系,我们应该知道进程这么多状态中有一种状态是僵死状态,就是进程终止后进入僵死状态(zombie),等待告知父进程自己终止,后才能完全消失.但是如果一个进程已经终止了,但是其父进程还没有获取其状态,那么这个进程就称之为僵尸进程.僵尸进程还会消耗一定的系统资源,并且还保留一些概要信息供父进程查询子进程的状态可以提供父进程想要的信息.一旦父进程得到想要的信息,僵尸进程就会结束.

处理:
所以对于子进程我们要进行合理的回收,linux中提供wait和waitpid这两个函数处理子进程回收问题。

pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);

具体的函数定义各位可以搜一搜

惊群效应
惊群简单来说就是多个进程或者线程在等待同一个事件,当事件发生时,所有线程和进程都会被内核唤醒。唤醒后通常只有一个进程获得了该事件并进行处理,其他进程发现获取事件失败后又继续进入了等待状态,在一定程度上降低了系统性能。

https://blog.youkuaiyun.com/abhem4170/article/details/101951466

二、线程

2.1 线程的创建

线程和进程的区别也就只是共享程度的区别,linux中线程的创建是直接clone一个进程,然后设置它的共享程度。

在这里插入图片描述

2.2 线程的同步(synchronization)

synchronization的定义:
多线程执行的结果不受执行顺序的影响。

受执行顺序影响的例子:
两个线程分别执行如下代码:
在这里插入图片描述
在这里插入图片描述
不同的执行顺序有不同的结果,也就是说线程的结果,受执行顺序的影响。

同步机制:
1、Mutex
2、condition varaible
3、Semaphore
4、Monitor(java的同步方式)

锁的种类:

  • 互斥量
    互斥量一般和条件变量配合使用。使用资源前上锁,使用结束后通知阻塞的线程

  • 自旋锁
    自旋锁也是广义上的互斥锁, 是互斥锁的实现方式之一, 它不会产生线程的调度, 而是通过"循环"来尝试获取锁, 优点是能很快的获取锁, 缺点是会占用过多的CPU时间, 这被称为忙等待(busy-waiting).

  • 读写锁
    在互斥锁中, 只有两个状态: 加锁和未加锁, 而在一些情况下对于"读"可以并发的进行而不用加锁, 对于读则需要加锁, 比如golang中map的操作.

为了让"读"操作更快的进行(不必加锁), 就诞生了"读写锁"的概念, 它有三个状态: 读模式下加锁状态, 写模式加锁状态和未加锁状态.

规则如下
1、如果有其它线程读数据, 则允许其它线程执行读操作, 但不允许写操作
2、如果有其它线程写数据, 则其它线程都不允许读和写操作

由于这个特性, 读写锁能在读频率更高的情况下有更好的并发性能.

  • barrier(这个不知道怎么翻译)

代码

这几种的区别,欢迎浏览讨论

2.1.1 互斥锁和条件变量

关于条件变量为什么需要加一个互斥锁的问题?
https://www.zhihu.com/question/53631897

多线程中join 和 detach的区别

每个进程都有一个主线程负责执行,一般为main函数里面的线程。

主线程结束,子线程也得结束。

线程环境:

线程存在于进程之中,进程内所有全局资源对于内部每个线程都是可见的。

进程内典型全局资源如下:

1)代码区:这意味着当前进程空间内所有的可见的函数代码,对于每个线程来说,也是可见的

2)静态存储区:全局变量,静态空间

3)动态存储区:堆空间

线程内典型的局部资源:

1)本地栈空间:存放本线程的函数调用栈,函数内部的局部变量等

2)部分寄存器变量:线程下一步要执行代码的指针偏移量

一个进程发起后,会首先生成一个缺省的线程,通常称这个线程为主线程,C/C++程序中,主线程就是通过main函数进入的线程,由主线程衍生的线程成为从线程,从线程也可以有自己的入口函数,相当于主线程的main函数,这个函数由用户指定。通过thread构造函数中传入函数指针实现,在指定线程入口函数时,也可以指定入口函数的参数。就像main函数有固定的格式要求一样,线程的入口函数也可以有固定的格式要求,参数通常都是void类型,返回类型根据协议的不同也不同,pthread中是void,winapi中是unsigned int,而且都是全局函数。

最常见的线程模型中,除主线程较为特殊之外,其他线程一旦被创建,相互之间就是对等关系,不存在隐含的层次关系。每个进程可创建的最大线程数由具体实现决定。

无论在windows中还是Posix中,主线程和子线程的默认关系是:无论子线程执行完毕与否,一旦主线程执行完毕退出,所有子线程执行都会终止。这时整个进程结束或僵死,部分线程保持一种终止执行但还未销毁的状态,而进程必须在其所有线程销毁后销毁,这时进程处于僵死状态。线程函数执行完毕退出,或以其他非常方式终止,线程进入终止态,但是为线程分配的系统资源不一定释放,可能在系统重启之前,一直都不能释放,终止态的线程,仍旧作为一个线程实体存在于操作系统中,什么时候销毁,取决于线程属性。在这种情况下,主线程和子线程通常定义以下两种关系:

1、可会合(joinable):这种关系下,主线程需要明确执行等待操作,在子线程结束后,主线程的等待操作执行完毕,子线程和主线程会合,这时主线程继续执行等待操作之后的下一步操作。主线程必须会合可会合的子线程。在主线程的线程函数内部调用子线程对象的wait函数实现,即使子线程能够在主线程之前执行完毕,进入终止态,也必须执行会合操作,否则,系统永远不会主动销毁线程,分配给该线程的系统资源也永远不会释放。

2、相分离(detached):表示子线程无需和主线程会合,也就是相分离的,这种情况下,子线程一旦进入终止状态,这种方式常用在线程数较多的情况下,有时让主线程逐个等待子线程结束,或者让主线程安排每个子线程结束的等待顺序,是很困难或不可能的,所以在并发子线程较多的情况下,这种方式也会经常使用。

在任何一个时间点上,线程是可结合(joinable)或者是可分离的(detached),一个可结合的线程能够被其他线程回收资源和杀死,在被其他线程回收之前,它的存储器资源如栈,是不释放的,相反,一个分离的线程是不能被其他线程回收或杀死的,它的存储器资源在它终止时由系统自动释放。

线程的分离状态决定一个线程以什么样的方式来终止自己,在默认的情况下,线程是非分离状态的,这种情况下,原有的线程等待创建的线程结束,只有当pthread_join函数返回时,创建的线程才算终止,释放自己占用的系统紫云啊,而分离线程没有被其他的线程所等待,自己运行结束了,线程也就终止了,马上释放系统资源。

三、线程模型

1、连接独占线程或进程

2、单线程reactor

3、N:1

4、多线程reactor

5、M:N

这里以Linux为例。Linux历史上,最开始使用的线程是LinuxThreads,但LinuxThreads有些方面受限于内核的特性,从而违背了SUSV3 Pthreads标准。即它要根据内核的特性来实现线程,有些地方没有遵循统一的标准。后来IBM开发了NGPT(Next Generation POSIX Threads),性能明显优于LinuxThreads,人们曾把它当作LinuxThreads的继任者。但最后,又有一个项目NPTL(Native POSIX Threads Library)出来后,性能更优于NGPT。2002年NGPT项目停止开发,我们现在用的Linux线程就是NPTL。

线程的实现曾有3种模型:

1.多对一(M:1)的用户级线程模型

2.一对一(1:1)的内核级线程模型

3.多对多(M:N)的两级线程模型

上面的x对y(x:y)即x个用户线程对应y个内核调度实体(Kernel Scheduling Entity,这个是内核分配CPU的对象单位)。

LinuxThreads和NPTL都是采用一对一的线程模型,NGPT采用的是多对多的线程模型!!!

二.多对一用户线级程模型

多对一线程模型中,线程的创建、调度、同步的所有细节全部由进程的用户空间线程库来处理。用户态线程的很多操作对内核来说都是透明的,因为不需要内核来接管,这意味不需要内核态和用户态频繁切换。线程的创建、调度、同步处理速度非常快。当然线程的一些其他操作还是要经过内核,如IO读写。这样导致了一个问题:当多线程并发执行时,如果其中一个线程执行IO操作时,内核接管这个操作,如果IO阻塞,用户态的其他线程都会被阻塞,因为这些线程都对应同一个内核调度实体。在多处理器机器上,内核不知道用户态有这些线程,无法把它们调度到其他处理器,也无法通过优先级来调度。这对线程的使用是没有意义的!

三.一对一内核极线程模型

一对一模型中,每个用户线程都对应各自的内核调度实体。内核会对每个线程进行调度,可以调度到其他处理器上面。当然由内核来调度的结果就是:线程的每次操作会在用户态和内核态切换。另外,内核为每个线程都映射调度实体,如果系统出现大量线程,会对系统性能有影响。但该模型的实用性还是高于多对一的线程模型。

四.多对多两极线程模型

多对多模型中,结合了1:1和M:1的优点,避免了它们的缺点。每个线程可以拥有多个调度实体,也可以多个线程对应一个调度实体。听起来好像非常完美,但线程的调度需要由内核态和用户态一起来实现。可想而知,多个对象操作一个东西时,肯定要一些其他的同步机制。用户态和内核态的分工合作导致实现该模型非常复杂。NPTL曾经也想使用该模型,但它太复杂,要对内核进行大范围改动,所以还是采用了一对一的模型!!!

四、一些比较典型的题目

2.1 什么时候用多线程?什么时候用多进程?

线程和进程的区别如下:
1、需要频繁创建销毁的优先使用线程;因为对进程来说创建和销毁一个进程代价是很大的。

2、线程的切换速度快,所以在需要大量计算,切换频繁时用线程,还有耗时的操作使用线程可提高应用程序的响应

3、因为对CPU系统的效率使用上线程更占优,所以可能要发展到多机分布的用进程,多核分布用线程;

4、并行操作时使用线程,如C/S架构的服务器端并发线程响应用户的请求;

5、需要更稳定安全时,适合选择进程;需要速度时,选择线程更好。

通过1\2可以得出如下结论:
I/O密集型用多线程,因为I/O会涉及频繁的切换,用多线程开销比较小
计算密集型用多进程,因为进程是资源的单位,有多核CPU的话,搞多个进程,比较快

2.2、常见C++多线程面试题

#include<iostream>
#include<thread>
#include<mutex>
#include<condition_variable>
using namespace std;
mutex m;
condition_variable cond;
int flag=10;
void fun(int num){
    for(int i=0;i<50;i++){
        unique_lock<mutex> lk(m);//A unique lock is an object that manages a mutex object with unique ownership in both states: locked and unlocked.
        while(flag!=num)
            cond.wait(lk);//在调用wait时会执行lk.unlock()
        for(int j=0;j<num;j++)
            cout<<j<<" ";
        cout<<endl;
        flag=(num==10)?100:10;
        cond.notify_one();//被阻塞的线程唤醒后lk.lock()恢复在调用wait前的状态
    }
}

int main(){
    thread child(fun,10);
    fun(100);
    child.join();
    return 0;
}

参考文献

[1] https://www.cnblogs.com/fah936861121/articles/8043187.html

[2] 多线程模型:https://github.com/apache/incubator-brpc/blob/master/docs/cn/threading_overview.md

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值