后台开发:核心技术与应用实践--线程与进程间通信

多线程

进程在多数早期多任务操作系统中是执行工作的基本单元。进程是包含程序指令和相关资源的集合,每个进程和其他进程一起参与调度,竞争 CPU 、内存等系统资源。每次进程切换,都存在进程资源的保存和恢复动作,这称为上下文切换。进程的引入可以解决多用户支持的问题,但是多进程系统也在如下方面产生了新的问题:进程频繁切换引起的额外开销可能会严重影响系统性能。

同一个进程内部的多个线程,共享的是同一个进程的所有资源。比如,与每个进程独有自己的内存空间不同,同属一个进程的多个线程共享该进程的内存空间。通过线程可以支持同一个应用程序内部的并发,免去了进程频繁切换的开销,另外并发任务间通信也更简单。线程的切换是轻量级的,所以可以保证足够快。每当有事件发生状态改变,都能有线程及时响应,而且每次线程内部处理的计算强度和复杂度都不大。

一个栈中只有最下方的帧可被读写,相应的,也只有该帧对应的那个函数被激活,处于工作状态。为了实现多线程,则必须绕开栈的限制。为此,在创建一个新的线程时,需要为这个线程建一个新的栈,每个栈对应一个线程,当某个栈执行到全部弹出时,对应线程完成任务,并结束。所以,多线程的进程在内存中有多个栈,多个栈之间以一定的空白区域隔开,以备栈的增长。每个线程可调用自己栈最下方的帧中的参数和变量,并与其他线程共享内存中的 Text、heap和global data 区域。要注意的是,对于多线程来说,由于同一个进程空间中存在多个栈,任何一个空白区域被填满都会导致栈溢出

在并发情况下,指令执行的先后顺序由内核决定。同一个线程内部,指令按照先后顺序执行,但不同线程之间的指令很难说清楚哪一个会先执行,如果运行的结果依赖于不同线程执行的先后的话,那么就会造成竞争条件,在这样的状况下,计算机的结果很难预知,所以
应该尽量避免竞争条件的形成。最常见的解决竞争条件的方法是将原先分离的两个指令构成不可分割的一个原子操作,而其他任务不能插入到原子操作中。

对于多线程程序来说,同步是指在一定的时间内只允许某一个线程访问某个资源。而在此时间内,不允许其他的线程访问该资源,可以通过互斥锁(mutex) 、条件变量(condition variable)、读写锁(reader-writer lock)和信号量(emphore)来同步资源。

  1. 互斥锁

互斥锁是一个特殊的变量,它有锁上(lock)和打开(unlock)两个状态。互斥锁一般被设置成全局变量,打开的互斥锁可以由某个线程获得。一旦获得,这个互斥锁会锁上,此后只有该线程有权打开,其他想要获得互斥锁的线程, 会等待直到互斥锁再次打开的时候。我们可以将互斥锁想象成一个只能容纳一个人的洗手间, 当某个人进入洗手间的时候,可以从里面将洗手间锁上,其他人只能在互斥锁外面等待那个人出来,才能进去。但在外面等候的人并没有排队,谁先看到洗手间了,就可以首先冲进去。

  1. 条件变量

互斥量是线程程序必需的工具,但并非是万能的。例如,如果线程正在等待共享数据内某个条件出现,那会发生什么呢?它可能重复对互斥对象锁定和解锁,每次都会检查共享数据结构,以查找某个值。但这是在浪费时间和资源,而且这种繁忙查询的效率非常低。
在每次检查之间,可以让调用线程短暂地进入睡眠,比如睡眠3秒,但是由此线程代码就无法最快作出响应。真正需要的是这样一种方法,当线程在等待满足某些条件时使线程进入睡眠状态,一旦条件满足,就唤醒因等待满足特定条件而睡眠的线程。如果能够做到这一点,线程代码将是非常高效的,并且不会占用宝贵的互斥对象锁。而这正是条件变量能做的事!
条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补互斥锁的不足,它常和互斥锁一起使用。使用时,条件变量被用来阻塞一个线程,当条件不满足时,线程往往解开相应的互斥锁并等待条件发生变化。一旦其他的某个线程改变了条件变量,它将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程,这些线程将重新锁定互斥锁并重新测试条件是否满足。
条件变量特别适用于多个线程等待某个条件的发生。如果不使用条件变量,那么每个线程就需要不断获得互斥锁并检查条件是否发生,这样大大浪费了系统的资源。

  1. 读写锁

对某些资源的访问会存在两种可能的情况,一种是访问必须是排他性的,就是独占的意思,这称作写操作;另一种情况就是访问方式可以是共享的,就是说可以有多个线程同时去访问某个资源,这种就称作读操作。可以有多个线程同时占用读模式的读写锁,但是只能有一个线程占用写模式的读写锁,读写锁的3种状态如下所述。

  1. 当读写锁是写加锁状态时,在这个锁被解锁之前,所有试图对这个锁加锁的线程都会被阻塞
  2. 当读写锁在读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权,但是以写模式对它进行加锁的线程将会被阻塞
  3. 当读写锁在读模式的锁状态时,如果有另外的线程试图以写模式加锁,读写锁通常会阻塞随后的读模式锁的请求,这样可以避免读模式锁长期占用,而等待的写模式锁请求则长期阻塞。
  1. 信号量

信号量和互斥锁的区别:互斥锁只允许一个线程进入临界区,而信号量允许多个线程同时进入临界区。

可重入函数

所谓“可重入函数”,是指可以由多于一个任务并发使用,而不必担心数据错误的函数。相反,“不可函数”则是只能由一个任务所占用,除非能确保函数的互斥(或者使用信号量,或者在代码的关键部分禁用中断)。可重入函数可以在任意时刻被中断,稍后再继续运行,且不会丢失数据,可重入函数要在使用本地变量或在使用全局变量时保护自己的数据。

可重入函数有以下特点

  1. 不为连续的调用持有静态数据
  2. 不返回指向静态数据的指针
  3. 所有数据都由函数的调用者提供
  4. 使用本地数据,或者通过制作全局数据的本地副本来保护全局数据
  5. 如果必须访问全局变量,要利用互斥锁、信号量等来保护全局变量
  6. 绝不调用任何不可重入函数

不可重入函数有以下特点

  1. 函数中使用了静态变量,无论是全局静态变量还是局部静态变量
  2. 函数返回静态变量
  3. 函数中调用了不可重人函数
  4. 函数体内使用了静态的数据结构
  5. 函数体内调用了 malloc() 或者的 free() 函数
  6. 函数体内调用了其他标准 I/O 函数

编写的多线程程序,通过定义宏 _REENTRANT 来告诉编译器需要可重人功能,这个宏的定义必须出现于程序中的任何 #include 语句之前,它将为我们做三件事:

  1. 它会对部分函数重新定义它们的可安全重入的版本
  2. stdio.h 中原来以宏的形式出现的一些函数将变成可安全重入函数
  3. 在 error.h 中定义的变量 error 现在将成为一个函数调用,它能够以一种安全的多线程方式来获取真正的 errno 的值

进程

进程,是计算机中处于运行中程序的实体。以前,进程是最小的运行单位;有了线程之后,线程成为最小的运行单位,而进程则是线程的容器。

进程结构一般由3部分组成:代码段、数据段和堆栈段。代码段是用于存放程序代码的数据,假如机器中有数个进程运行相同的一个程序,那么它们就可以使用同一个代码段。而数据段则存放程序的全局变量、常量和静态变量。堆栈段中的栈用于函数调用,它存放着函数的参数、函数内部定义的局部变量。堆栈段还包括了进程控制块(Process Control Block, PCB), 处于进程核心堆栈的底部,不需要额外分配空间,是进程存在的唯一标识,系统通过PCB的存在而感知进程的存在。系统通过PCB对进程进行管理和调度。PCB包括创建进程、执行程序、退出进程以及改变进程的优先级等。

进程的创建有两种方式:一种是由操作系统创建,一种是由父进程创建。

Linux 系统下使用 fork() 函数创建一个子进程,其函数原型如下:

#include <unistd.h>
pid_t fork(void);

fork()函数不需要参数,返回值是一个进程标识符(PID)。对于返回值,有以下3种情况:

  1. 对于父进程, fork() 函数返回新创建的子进程的 ID
  2. 对于子进程, fork() 函数返回0
  3. 如果创建出错,则fork() 函数返回-1

fork() 函数会创建一个新的进程,并从内核中为此进程分配一个新的可用的进程标识符(PID),之后,为这个新进程分配进程空间,并将父进程的进程空间中的内容复制到子进程的进程空间中,包括父进程的数据段和堆栈段,并且和父进程共享代码段。这时候,系统中又多了一个进程,这个进程和父进程一样,两个进程都接受系统的调度。由于在复制时复制了父进程的堆栈段,所以两个进程都停留在了 fork() 函数中,等待返回。因此,fork() 函数会返回两次,一次是在父进程中返回,另一次是在子进程中返回,这两次的返回值是不一样的。

示例:

#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, const char* argv[])
{

    for(int i=0;i<2;++i)
    {
        if(fork()==0){
            printf("sub-process, pid: %d,ppid: %d\n",getpid(),getppid());
            exit(1);
        }else{
            printf("parent process, pid %d, ppid %d\n",getpid(),getppid());
        }
    }
    sleep(1);
    return 0;
}
/*
output:
parent process, pid 89607, ppid 5595
parent process, pid 89607, ppid 5595
sub-process, pid: 89608,ppid: 89607
sub-process, pid: 89609,ppid: 89607
*/

子进程完全复制了父进程的地址空间的内容,包括堆栈段和数据段的。但是,子进程并没有复制代码段,而是和父进程共用代码段。代码段是只读的,不存在被修改的问题。

Linux 环境下使用 exit()函数退出进程,其函数原型如下:

#include<stdlib.h> 
void exit(int status);

exit() 函数的参数表示进程的退出状态,这个状态的值是一个整型,保存在全局变量 $? 中。$? 是 Linux shell 中的一个内置变量,其中保存的是最近一次运行的进程的返回值。

在 UNIX/Linux 中,正常情况下,子进程是通过父进程创建的,子进程在创建新的进程。子进程的结束和父进程的运行是一个异步过程,即父进程永远无法预测子进程到底什么时候结束。于是就产生了孤儿进程和僵尸进程。

孤儿进程,是指一个父进程退出后,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被 init 进程(进程号为 1)所收养,并由 init 进程对它们完成状态收集工作,并会周期性地调用 wait 系统调用来清除各个僵尸的子进程。

僵尸进程,是指一个进程使用 fork 创建子进程,如果子进程退出,而父进程并没有调 wait 或 waitpid 获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中,这种进程称为僵尸进程。当一个进程完成它的工作终止之后,它的父进程需要调用 wait() 或者 waitpid() 系统调用取得子进程的终止状态。

进程一旦调用了 wait 函数,就立即阻塞自己,由 wait 自动分析是否当前进程的某个子进程已经退出,如果让它找到了这样一个已经变成僵尸的子进程, wait 就会收集这个子进程的信息,并把它彻底销毁后返回;如果没有找到这样一个子进程, wait 就会一直阻塞在这里,直到有一个出现为止。函数原型为:

#include<sys/types.h> 
#include<sys/wait.h>
pid_t wait(int * status);

wait() 会暂时停止目前进程的执行,直到有信号来到或子进程结束 如果在调用 wait() 时子进程已经结束,则 wait() 会立即返回子进程结束状态值。子进程的结束状态值会由参数 status 返回,而子进程的进程识别码也会一快返回。如果不需要结束状态值,则参数 status 可以设成 NULL。

守护进程是脱离于终端并且在后台运行的进程。守护进程脱离于终端是为了避免进程在执行过程中的信息在任何终端上显示并且进程也不会被任何终端所产生的终端信息所打断。守护进程是一个生存期较长的进程,通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。守护进程常常在系统引导装入时启动,在系统关闭时终止。

进程间通信

进程间通信就是在不同进程之间传播或交换信息,用于进程间通信的方法主要有:管道、消息队列、共享内存、信号量、套接字等。其中,前面4种主要用于同一台机器上的进程间通信,而套接字则主要用于不同机器之间的网络通信。

  1. 管道

管道是一种两个进程间进行单向通信的机制,因为管道传递数据的单向性,管道又称为半双工管道,管道的这一特点决定了其使用的局限性,具有以下特点:

  1. 数据只能由一个进程流向另一个进程(其中一个读管道,一个写管道),如果要进行双工通信,则需要建立两个管道
  2. 管道只能用于父子进程或者兄弟进程间通信,也就是说管道只能用于具有亲缘关系的进程间通信。
    从本质上说,管道也是一种文件,但它又和一般的文件有所不同,可以克服使用文件进行通信的两个问题,这个文件只存在内存中
    通过管道通信的两个进程,一个进程向管道写数据,另外一个从中读数据。写入的数据每次都添加到管道缓冲区的末尾,读数据的时候都是从缓冲区的头部读出数据的
    管道存在有名和无名的区别,其中,对于无名管道来说,只能进行有亲缘关系的进程间的通信,而有名管道与无名管道的区别就是提供了一个路劲名与之关联,以FIFO的文件形式存在于系统中。这样,即使 FIFO 的创建进程不存在亲缘关系,只要可以访问该路径,就能够彼此通过 FIFO 相互通信。有名管道与无名管道的区别:
  1. 消息队列

消息队列用于运行于同一台机器上的进程间通信,它和管道很相似,是一个在系统内核中用来保存消息的队列,它在系统内核中是以消息链表的形式出现,消息链表中节点的结构用msg声明
消息队列跟有名管道有不少的相同之处,消息队列进行通信的进程可以是不相关的进程,同时它们都是通过发送和接收的方式来传递数据的。在命名管道中, 发送数据用 write 函数,接收数据用 read 函数,则在消息队列中,发送数据用 msgsnd 函数,接收数据用 msgrcv 函数。而且它们对每个数据都有一个最大长度的限制。
与命名管道相比,消息队列的优势在于: 1. 消息队列也可以独立于发送和接收进程而存在,从而消除了在同步命名管道的打开和关闭时可能产生的困难;2. 可以同时通过发送消息以避免命名管道的同步和阻塞问题,而不需要由进程自己来提供同步方法;3. 接收程序可以通过消息类型有选择地接收数据,而不是像命名管道中那样,只能默认地接收

  1. 共享内存

共享内存就是允许两个不相关的进程访问同一个逻辑内存。共享内存是在两个正在运行的进程之间共享和传递数据的一种非常有效的方式。不同进程之间共享的内存通常安排在同一段物理内存中。进程可以将同一段共享内存连接到它们自己的地址空间中,所有进程都可以访问共享内存中的地址。不过,共享内存并未提供同步机制。
使用共享内存的优缺点如下所述:
优点:使用共享内存进行进程间的通信非常方便,而且函数的接口也简单,数据的共享还使进程间的数据不用传送,而是直接访问内存,也加快了程序的效率。同时,它也不像无名管道那样要求通信的进程有一定的父子关系
缺点:共享内存没有提供同步的机制,这使得在使用共享内存进行进程间通信时,往往要借助其他的手段来进行进程间的同步工作

  1. 信号量
posted @ 2021-03-30 09:49  范中豪  阅读(183)  评论(0编辑  收藏  举报