进程与线程及进程间线程间通信

一.进程

  • 进程是资源分配的基本单位。
  • 进程控制块(PCB)描述进程的基本信息和运行状态,例如创建进程与销毁进程都是指对PCB的操作。

二.线程

  • 线程是独立调度的基本单位。
  • 一个进程中可以有多个线程,共享进程资源。
  • 浏览器是一个进程,里面有很多线程,例如HTTP请求线程,事件响应线程,渲染线程等等。而线程的并发执行使得在浏览器点击一个链接从而发起HTTP请求时,浏览器还可以响应用户的其它事件。

三.进程与线程的区别

  • 进程(Process)是系统进行资源分配和调度的基本单位,线程(Thread)是CPU调度和分配的基本单位。
  • 线程是独立调度的基本单位,在同一进程中,线程的切换不会引起进程切换,跨进程的线程切换会引起进程切换。
  • 线程依赖于进程存在,一个进程至少有一个线程。
  • 进程有自己独立的地址空间,线程共享所属进程的地址空间,但每个线程有自己的私有栈。
  • 创建和销毁进程时,系统都要为之分配或回收资源,如内存空间,I/O设备等,所付出的开销远大于创建或销毁线程。同样进程切换涉及当前CPU环境的保存及新调度进程CPU环境的设置,线程切换只需保存和设置少量寄存器的内容,开销很小。
  • 进程间通信(IPC)需要进程同步和互斥手段的辅助,以保证数据一致性。而线程可以通过直接读/写同一进程中的数据段(.data全局变量)来进行通信。
  • 多线程程序只要有一个线程崩溃,整个程序就崩溃了。但多进程程序一个进程的崩溃并不会影响其它进程,因为每个进程有自己独立的地址空间,因此进程更具有健壮性。

同一进程中的线程共享哪些数据?

  • 进程代码段
  • 进程公有数据(全局,静态变量…)
  • 进程打开的文件描述符表
  • 文件系统相关信息:文件权限掩码(umask),当前工作目录
  • 信号处理函数
  • 进程ID和父进程ID
  • 进程组ID和会话ID
  • 用户ID和用户组ID

线程独占哪些资源?

  • 线程ID
  • 一组寄存器的值
  • 线程自身栈(堆与其它线程共享):本地变量和函数的调用链接信息
  • 错误(error)返回码:一个线程的错误返回码不应该被其它线程更改
  • 信号掩码/信号屏蔽字(Signal Mask):表示是否屏蔽/阻塞相应信号(SIGKILL,SIGSTOP除外)
  • 实时调度策略和优先级

线程与进程的上下文切换

当线程进行上下文切换会将自身寄存器的状态保存到(TCB,Thread Control Block)里(进程同理,是PCB),然后恢复另一个线程的上下文。
与进程上下文切换的区别在于线程只需要切换处理机执行的上下文,不会改变地址空间。
优点:不需要重新加载页表,切换开销少,提高效率
缺点:多个线程共享地址空间,有利(线程间通信)有弊(并发问题)

四.进程间通信(IPC)的方式?

  • 信号(Signal)
  • 管道(匿名管道:Pipe,命名管道:FIFO)
  • 信号量(Semaphore)
  • 共享内存(Shared memory)
  • 消息队列(Message queue)
  • 套接字(Socket)
方式 传输的信息量 使用场景 关键词
信号 少量 任何 硬件来源,软件来源/信号队列
(匿名)管道 大量 亲缘(父子 ,兄弟等)进程间 单向流动/内核缓冲区/循环队列/没有格式的字节流/操作系统负责同步
命名管道 大量 任何 磁盘文件/访问权限/无数据块/内核缓冲区/操作系统负责同步
信号量 N 任何 互斥同步/原子性/P减V增
共享内存 大量 多个进程 简单快速/操作系统不负责同步
消息队列 比信号多但有限制 任何 有格式/按消息类型过滤/操作系统负责同步
套接字 大量 不同主机的进程 读缓冲区/写缓冲区/操作系统负责同步

1.信号

信号是Linux系统响应某些条件而产生的事件,接收到该信号的进程可以采取自定义的行为(siganl-handler)。Linux系统通过kill函数发送信号。
信号来源可分为硬件来源和软件来源:

  1. 硬件来源。例如:按下ctrl+C,除0,非法内存访问等。
  2. 软件来源。如kill命令,Alarm Clock超时,当Reader中止后又向管道写数据等。

一般信号由错误产生,例如除0会引发0号中断,编号#DE,这是硬件级中断,导致陷入内核,执行IDT(中断描述符表)中的中断处理程序。而操作系统处理这个异常的方法就是向进程发送一个SIGFPE信号。若进程设置了自己的signal handler,就执行进程的处理方法否则一致执行操作系统的默认操作。下图是Linux信号行为与事件:

硬件中断由操作系统处理,将这些硬件异常包装成信号发送给进程。这是操作系统与进程间的通信。既然操作系统可以,同样,信号也可以作为进程之间通信的方式,这就需要进程发送信号,接收信号,处理信号三步:

进程如何发送信号?

  • 操作系统提供发送信号的系统调用
  • 该系统调用会将信号放到目标进程的信号队列中
  • 如果目标进程的状态不是执行态,则该信号由内核保存,直到该进程恢复执行并传递给它为止。若一个信号被进程设置为阻塞,则该信号传递被延迟,直到其阻塞被取消时才被传递给进程
  • 除了用于进程间通信,进程还可以发送信号给进程本身

进程如何接收信号?

  • 每个进程均有一个信号队列,其中存放其它进程发给它,等待它处理的信号
  • 进程在执行过程中的特定时刻,检查并处理自身的信号队列。例如从系统空间返回用户空间之前
  • 发送信号时,必须指明发送目标进程的进程ID。一般用在亲缘关系进程间。

用户进程对信号的处理

  1. 处理信号。定义信号处理函数(signal handler),当进程接收到指定信号,执行相应的处理函数
  2. 忽略信号。当不希望接收到的信号对进程的执行产生影响,而让进程继续执行时,可以忽略该信号,不对信号进程做任何处理
  3. 不处理也不忽略。执行默认操作,由Linux操作系统规定默认操作,有5种默认处理动作:
  • Term 终止进程
  • lgn 当前进程忽略掉这个信号
  • Core 终止进程,并生成一个Core文件,这个文件可在调试带调试信息的可执行程序时显示收到了什么信号和终止进程的原因
  • Stop 暂停当前进程
  • Cont 继续执行当前被暂停的进程

信号有三种状态:产生,未决,递达
有两种信号SIGKILL和SIGSTOP不能被捕捉,阻塞或忽略,只能执行默认操作

信号的特点

  1. 简单
  2. 不能携带大量信息
  3. 满足某个特定条件才发送
  4. 优先级比较高

2.管道

管道也叫无名(匿名)管道,是Unix系统IPC的最古老形式,我们使用管道操作符|来表示两个命令之间的数据通信,例如统计一个目录中文件的数目ls | wc -l,为了执行该命令,shell创建两个进程来分别执行ls和wc,各个进程的标准输出stdout,会作为下一个进程的标准输入stdin。

管道的特点

  • 管道其实是一个在内核内存中维护的缓冲器,这个缓冲器的存储能力是有限的,不同操作系统大小不一定相同。缓冲区被设计成为环形的数据结构,以便管道可以被循环利用(循环队列)。
  • 管道拥有文件的特质:读,写操作,匿名管道没有文件实体,命名管道有文件实体,但不存储数据,可以按照操作文件的方式对管道进行操作。
  • 一个管道是一个字节流,使用管道不存在消息或消息边界的概念,从管道读取数据的进程可以读取任意大小的数据块,而不管写入进程和管道的数据块大小是多少。
  • 通过管道传递的数据是顺序的,从管道中读取出来的字节的顺序和它们被写入管道的顺序完全一致。
  • 在管道中数据的传递方向是单向的,一端用于写入,一端用于读取,是半双工的。
  • 从管道读数据是一次性操作,数据一旦被读走,它就从管道中被抛弃,释放空间以便写更多的数据,在管道中无法使用lseek()来随机的访问数据。

创建管道

通过pipe()系统调用来创建并打开一个管道,当最后一个使用它的进程关闭对它的引用时,pipe自动撤销。
查看管道缓冲区大小的命令:
ulimit -a
查看管道缓冲区大小函数:
#include <unistd.h>
long fpathconf(int fd, int name);

管道的同步

  • 操作系统保证读写进程的同步
  • 下游进程或上游进程需等待另一方释放锁才能操作管道。管道相当于一个文件,同一时刻只能有一个进程访问
  • 当管道为空时,下游进程读阻塞;管道满时,上游进程写阻塞

3.命名管道

为了克服命名管道只能用于亲缘关系的进程间通信,提出了命名管道(FIFO)。
Pipe与FIFO除了建立,打开和删除的方式不同外,几乎一模一样。

创建命名管道

通过mknode()系统调用或mkfifo()函数建立命名管道。一旦建立,任何有访问权限的进程都可以通过文件名将其打开和进行读写,不局限于父子进程。
建立命名管道时,会在磁盘创建一个索引节点,命名管道的名字就相当于索引节点的文件名。索引节点设置了进程的访问权限,但是没有数据块。命名管道实质上也是通过内核缓冲区来实现数据传输。有访问权限的进程,可以通过磁盘的索引节点来读写这块缓冲区。

与匿名管道的区别

  • FIFO在文件系统中作为一个特殊文件存在,但FIFO中的内容却存放在内存中。
  • 当使用FIFO的进程退出后,FIFO文件将继续保存在文件系统中以便以后使用。
  • FIFO有名字,从而使不相关的进程可以通过打开有名管道通信。

4.信号量

信号量是一种特殊变量,与之进行的操作都是原子的,有两种操作V(signal())和P(wait())。V操作增加信号量S的数值N,P操作减少它。

  • V(S):如果有其他进程因等待S而被挂起,就让它恢复运行,否则S加1
  • P(S):如果S为0,则挂起进程,否则S减1

如果信号量是一个任意的整数,通常被称为计数信号量(Counting semaphore)或一般信号量(General semaphore);
如果信号量只有二进制的0或1,称为二进制信号量(Binary semaphore)。在Linux系统中,二进制信号量又称互斥锁(Mutex)。信号量可以用于实现进程或线程的互斥和同步。

信号量在底层的实现是通过硬件提供的原子指令,例如Test And Set,Compare And Swap等。

5.共享内存

共享内存允许两个或者多个进程共享物理内存的同一块区域(通常称为段)。由于一个共享内存段会成为一个进程用户空间的一部分,因此这种IPC机制无需内核介入,需要做的仅仅是让一个进程将数据复制进共享内存中,并且这部分数据对其他所有共享同一个段的进程可用。

共享内存的优点与缺点

  1. 优点:简单且高效,访问共享内存区域和访问进程独有的内存区域一样快,原因在于只要进程绑定了共享内存段,就不再需要系统调用,不涉及用户态到内核态的转换,也不需要对数据不必要的复制。
  • 相比于管道与消息队列,后两者需要在内核和用户空间进行四次的数据拷贝(读输入文件,写到管道;读管道,写到输出文件),而共享内存则只拷贝两次:(1)从输入文件到共享内存区;(2)从共享内存到输出文件。除此之外,消息传递的实现经常采用系统调用,这就需要频繁进行用户态和内核态的切换;而共享内存只在建立共享内存区时需要系统调用,一旦建立,所有访问都可作为常规内存访问,无需借助内核。
  1. 缺点:存在并发问题,例如多个进程修改同一块内存,因此共享内存一般与信号量结合使用。

实现共享内存的三种方式

  • mmap
  • Posix shared memory
  • SystemV shared memory
类型 原理 易失性
mmap 利用文件(open)映射共享内存区域 会保存在磁盘上,不会丢失
Posix 利用/dev/shm文件系统(shm_open)映射共享内存区域 随内核持续,内核自举后会丢失
SystemV 利用特殊文件系统shm中的文件映射共享内存区域 随内核持续,内核自举后会丢失
  1. mmap()系统调用使得进程之间通过映射同一个普通文件实现共享内存。普通文件被映射到进程地址空间后,进程可以像访问普通内存一样对文件进行访问,不必调用read(),write()等。
  • 进程并不一定能够对全部新增空间都能进行有效访问。能访问的地址大小取决于文件被映射部分的大小。超过这个大小,内核根据超过的严重程度返回发送不同信号给进程

  1. Posix共享内存使用方法有以下两个步骤:
  • 通过shm_open创建或打开一个POSIX共享内存对象
  • 调用mmap将它映射到当前进程的地址空间

与mmap进行通信的使用上差别在于mmap描述符参数获取方式不一样,前者通过open后者通过shm_open。

  1. System V:系统调用mmap()映射一个普通文件实现共享内存。System V则通过映射特殊文件系统shm中的文件实现。每个共享内存区域对应特殊文件系统shm的一个文件。
  • 每一个共享内存区都有一个控制结构struct shmid_kernel,它是存储管理和文件系统结合起来的桥梁,该结构中最重要的一个域是shm_file,存储了将被映射文件的地址。
  • 调用shmat()的过程就相当于映射文件系统shm中同名文件过程,原理与mmap()大同小异。
#include <sys/ipc.h>
#include <sys/shm.h>
//获取共享内存区域
int shmget(key_t key, size_t size, int shmflg);
//连接共享内存区域
void* shmat(int shmid, const void* shmaddr, int shmflg);
//断开共享内存区域
int shmdt(const void* shmaddr);
//对共享内存区进行控制
int shmctl(int shmid, int cmd, struct shmid_ds* buf);

6.消息队列

  • 消息队列是一个消息的链表,保存在内核中。消息队列中的每个信息都是一个数据块,具有特定格式。操作系统可存在多个消息队列,每个消息队列有唯一key,称为消息队列标识符。
  • 消息队列与信号相比能够传递更多的信息;与管道相比提供了有格式的数据,并且取消息进程可以选择接收特定类型的信息,而管道默认全部接收;但消息队列仍有大小限制。
  • 消息队列是异步的。它允许一个或多个进程向它写入或读取消息,而消息的发送者和接收者不需要同时与消息队列交互。这带来优点的同时也有一个缺点:接收者必须轮询消息队列,才能收到最近的消息。
  • 消息队列的使用通过操作系统提供创建,接收,发送等系统调用;并且操作系统负责读写同步:若消息队列已满,写消息进程进入等待队列;若取消息进程未找到需要的消息,进入等待队列寻找。

7.套接字

  • 不同计算机进程之间的通信方式,也在用于同一台计算机的不同进程
  • 进程通过socket把消息发送到网络层,网络层通过主机地址发送给目的主机,目的主机通过端口号发送给对应进程
  • 操作系统提供各种系统调用,并为每个socket设置发送和接收缓冲区

五.线程间通信方式?(线程同步)

线程间通信方式

线程间的通信方式包括临界区互斥量信号量条件变量读写锁,这里仅对临界区做一介绍,后几种均在线程同步做有介绍:
临界区:每个线程中访问临界资源的那段代码称为临界区(Critical Section)(临界资源是一次仅允许一个线程使用的共享资源)。每次只准许一个线程进入临界区,进入后不允许其他线程进入。不论是硬件临界资源,还是软件临界资源,多个线程必须互斥地对它访问。

线程同步简介

线程相对于进程的主要优势在于能够通过全局变量来共享信息。这种便捷的共享在带来好处的同时也是有代价的:必须确保多个线程不会同时修改同一变量,或者某一线程不会读取正在由其他线程修改的变量。
线程同步:即当一个线程对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作,其他线程才能对该内存地址进行操作;而其他线程处于等待状态。

互斥量(互斥锁)

互斥量就是进程间通信方式信号量的一个特例—-二进制信号量
为避免线程更新共享变量出现问题,使用互斥量来确保同时仅有一个线程可以访问某项共享资源。使用互斥量来保证对任意共享资源的原子访问。

互斥量有已锁定(locked)和未锁定(unlocked)两种状态。任何时候,至多只有一个线程可以锁定该互斥量。试图对已经锁定的互斥量再一次加锁,将可能阻塞线程或者报错失败,具体取决于加锁时使用的方法。只有给互斥量加锁的所有者才能解锁,每一线程在访问共享资源分为以下步骤:

  • 针对共享资源锁定互斥量
  • 访问共享资源(临界区)
  • 对互斥量解锁

相关的操作函数:

互斥量类型 pthread_mutex_t
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);//初始化一个互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);//销毁
int pthread_mutex_lock(pthread_mutex_t *mutex);//加锁
int pthread_mutex_trylock(pthread_mutex_t *mutex);//尝试加锁(非阻塞)
int pthread_mutex_unlock(pthread_mutex_t *mutex);//解锁

读写锁

当有一个线程已经持有互斥锁时,互斥锁将所有试图进入临界区的线程均阻塞住。但是,若当前持有互斥锁的线程只是要访问共享资源,而同时有其他几个线程也想读取这个共享资源,由于互斥锁的排它性,所有线程均无法获取锁即无所读访问共享资源。实际上多个线程访问共享资源不会导致问题。

在实际情况中,对数据的读写操作更多的是读操作,写操作则比较少,例如对数据库的读写。读写锁的提出满足了多个线程同时共享资源,但只允许一个写入的需求。它有如下特点:

  • 若有其它线程读数据,则允许其它线程执行读操作,但不允许写操作
  • 若有其它线程写数据,则其它线程都不允许读,写操作
  • 写是独占的,优先级高
    相关函数:
读写锁类型 pthread_rwlock_t
int pthread_rwlock_init(pthread_rwlock_t *restrict mutex, const pthread_mutexattr_t *restrict attr);//初始化一个读写锁
int pthread_rwlock_destroy(pthread_mutex_t *mutex);//销毁
int pthread_rwlock_lock(pthread_mutex_t *mutex);//加锁
int pthread_rwlock_trylock(pthread_mutex_t *mutex);//尝试加锁(非阻塞)
int pthread_rwlock_unlock(pthread_mutex_t *mutex);//解锁

条件变量(条件变量+互斥锁=管程)

与互斥锁不同,条件变量是用来等待而不是用来上锁的。条件变量用来自动阻塞一个线程,直到某特殊情况发生为止。通常与互斥锁同时使用。

条件变量令我们可以睡眠等待条件出现。它是利用了线程间共享的全局变量进行同步的一种机制,主要包含两个动作:

  • 一个线程等待“条件变量的条件成立”而挂起
  • 一个线程使“条件成立”(给出条件成立信号)

条件的检测是在互斥锁的保护下进行的。如果一个条件为,则一个线程自动阻塞。并释放等待状态改变的互斥锁。若另一个线程改变了条件,该线程发送信号给关联的条件变量,唤醒一个或多个等待它的进程,并重新获得互斥锁,重新评价条件。

条件变量与信号量的区别:信号量有一个与之关联的状态(计数值),信号量挂出操作总会被记住。与之不同,当向一个条件变量发送信号时,若没有线程等待在该条件变量,那么该信号丢失。

互斥锁为了上锁而设计,条件变量为了等待而设计,信号量即可用于上锁,也可用于等待,因而可能导致更多的开销和更高的复杂度。
相关函数:

条件变量的类型 pthread_cond_t
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);//初始化
int pthread_cond_destroy(pthread_cond_t *cond);//销毁
intpthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);//阻塞等待条件成立
int pthread_cond_timedwait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict abstime);//超时等待条件成立
int pthread_cond_signal(pthread_cond_t *cond);//通知条件(改变条件状态以后再给线程发送信号)
int pthread_cond_broadcast(pthread_cond_t *cond);//解除所有线程的阻塞

信号量

与进程间通信的信号量同理,简单归纳一下信号量与管程的区别:

  • 信号量本质是可共享的资源的数量;而管程(条件变量+互斥锁)是一种抽象数据结构用来限制同一时刻只有一个线程进入临界区

  • 信号量可以并发执行,并发量取决于S的初始值;而管程同一时刻最多只能有一个线程执行

  • 信号量与管理的资源紧耦合(即信号量S的初始值等同于资源数目,且通过P V操作修改剩余可用的资源数量);而管程需自行判断是否还有可共享的资源

  • 信号量P操作可能阻塞,也可能不阻塞;管程wait操作一定会阻塞

  • 信号量V操作如果唤醒了其它线程,当前线程与被唤醒线程并发执行;而管程的signal操作,要么当前线程继续执行(Hansen:现代操作系统使用的方法),要么被唤醒线程继续执行(Hoare),二者不能并发

  • 信号量分为有名信号量和无名信号量,前者通过sem_open函数创建或打开,有名信号量即可用于线程间同步,也可用于进程间同步,是基于文件的信号量;无名信号量通过sem_init创建,是基于内存的信号量。

相关函数:

信号量的类型 sem_t
int sem_init(sem_t *sem, int pshared, unsigned int value);/*创建安无名信号量,若pshared为0,则初始化的信号量是在同一个进程的各个线程间共享;
若pshared非0值,那么信号量将在进程之间共享*/
int sem_destroy(sem_t *sem);//销毁无名信号量
int sem_wait(sem_t *sem);//使信号量S减1(原子操作)
int sem_trywait(sem_t *sem);//减1非阻塞版,返回错误代码EAGAIN
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);//超时等待减1版本
int sem_post(sem_t *sem);//信号量加1(原子操作)
int sem_getvalue(sem_t *sem, int *sval);//把sem指向信号量的值放置在sval指向的整数上

参考文章
共享内存详解

posted @ 2022-10-25 10:37  yytarget  阅读(226)  评论(0编辑  收藏  举报