云计算百科
云计算领域专业知识百科平台

进程线程与通信

一、为什么需要进程与线程

程序与进程的关系:程序是进程的静态基础,进程是程序运行的动态过程。操作系统运行的本质就是让进程占用内存与CPU资源完成计算任务。

对比项

程序

进程

存在形式

静态:存储在硬盘中的代码与数据的集合

动态:程序执行的过程(创建、调度、消亡)

生命周期

永久存在

暂时存在

状态变化

无

有(创建、就绪、运行、阻塞、消亡)

并发性

无

进程可以并发

资源占用

不占用运行资源

进程间竞争计算机资源

对应关系

一个程序可运行多次,变成多个进程

一个进程可以运行一个或多个程序

但一个CPU同一时刻只能执行一个任务,而系统中需要运行的任务有多个。要实现"在一个时间段内同时运行多个任务"(并发),就需要操作系统对进程、线程进行合理的管理与调度。

  • 进程是系统中最小的资源分配单位;
  • 线程是系统中最小的执行单位;
  • 进程之间资源隔离,互相竞争计算机资源。

二、总体分类

类别

核心内容

关键函数

进程控制

PCB、虚拟内存、进程状态、fork/退出/回收

fork、getpid、exit、wait/waitpid、exec族

线程控制

pthread创建/退出/回收、互斥锁、信号量、死锁

pthread_create、pthread_exit、pthread_join、pthread_mutex_、sem_

IPC进程间通信

管道、信号、共享内存

pipe、mkfifo、kill、signal、ftok、shmget、shmat

三、进程

1. PCB(进程控制块)

进程是一个程序执行的过程,执行时要分配内存资源与CPU资源。内核用PCB(Process Control Block)这个结构体描述正在运行的进程的全部信息。

PCB中的主要内容包括:

  • PID:进程标识符;
  • 当前工作路径(可用chdir修改);
  • umask(默认0002);
  • 进程打开的文件列表;
  • 信号相关设置(处理异步IO);
  • 用户id、组id;
  • 进程的资源限制(打开文件个数1024、栈大小8M等)。

2. 虚拟内存与MMU

Linux给每个进程一套虚拟内存,由MMU(内存管理单元)完成虚拟地址到物理地址的转换,主要为了两点:

  • 隔离性:多进程中,A进程不能访问B进程的内存空间;
  • 安全性:Linux系统本身也运行在内存上,需要通过权限管理控制对内核的访问,不允许进程随意访问内核。
  • 3. 进程分类与调度

    • 交互式进程:运行后需要用户输入,再给出对应输出;
    • 批处理进程:如shell脚本,批量执行命令;
    • 守护进程:自动运行,默认不需要输入,平时处于休眠状态,特定条件满足后自行开始工作(如系统更新、杀毒软件)。

    调度:系统中需要运行的任务多而CPU只有一个,操作系统需要合理安排下一时刻由哪个进程运行。常见调度算法:时间片轮转、短任务优先、进程优先级、完全公平调度算法(CFS)。

    宏观并行:一个时间段内每个进程都在运行;微观串行:某一时刻只有一个进程在运行。

    进程上下文切换:当a.out的CPU时间片耗尽需要运行b.out时,a.out要把自己的状态信息(PCB、硬件寄存器、PC、内存相关数据)缓存保存;b.out恢复自己之前缓存的数据后开始运行。

    4. 进程相关命令

    命令

    作用

    ps aux

    显示所有进程相关信息

    top

    Linux中的任务管理器

    kill -9 pid

    关闭指定pid的进程

    killall -9 a.out

    关闭所有名为a.out的进程

    5. fork创建子进程

    pid_t fork(void);

    功能:从当前进程克隆一个子进程。一次调用,返回两次。

    • 子进程复制父进程的0~3G空间与内核中的PCB,但pid不同;
    • 子进程的执行过程从fork函数之后开始,与父进程具有相同的代码逻辑;
    • 父进程中:成功返回子进程pid(>0),失败返回-1;
    • 子进程中:成功返回0。

    fork后父子进程谁先运行顺序不确定,且变量不共享。

    相关函数:

    pid_t getpid(void);    /* 获得调用进程的pid */

    pid_t getppid(void);   /* 获得调用进程的父进程pid */

    6. 写时复制

    子进程是父进程的复制品。Linux 2.6之后(如Ubuntu 18.04,内核5.4),子进程复制父进程内存空间时采用写时复制:

    刚fork完毕,子进程使用的内存空间全部共享父进程的;随着时间推移,父子进程修改某个变量或内存区域时,才真正开辟对应的内存空间。这项技术避免了不必要的复制开销。

    7. 进程的终止:8种情况

    类型

    情况

    正常终止

    main中return

    正常终止

    exit():C库函数,执行IO库清理、关闭所有流和已打开文件、执行atexit注册的清理函数

    正常终止

    _exit/_Exit:关闭所有已打开文件,不执行清理函数

    正常终止

    主线程退出

    正常终止

    主线程调用pthread_exit

    异常终止

    abort()

    异常终止

    signal:如kill pid

    异常终止

    最后一个线程被pthread_cancel

    exit与_exit对比:

    对比项

    exit

    _exit

    属性

    C库函数

    系统调用

    刷新缓冲区

    刷新

    不刷新

    atexit注册的清理函数

    执行

    不执行

    关闭文件

    关闭所有流及已打开文件

    关闭所有已打开文件

    void exit(int status);    /* 库函数:退出并刷新缓冲区 */

    void _exit(int status);   /* 系统调用:退出不刷新缓冲区 */

    exit的完整流程:exit -> 刷新缓冲区 -> atexit注册的退出函数 -> _exit。

    return在main函数中可结束进程,在其他函数中只结束该函数。

    8. 僵尸进程与孤儿进程

    僵尸进程与孤儿进程对比:

    对比项

    僵尸进程

    孤儿进程

    先消亡的一方

    子进程先消亡

    父进程先消亡

    用户内存空间

    已释放

    由新父进程接管

    内核PCB

    未释放,残留

    正常回收

    危害

    大量堆积导致内核内存不足、系统不稳定甚至崩溃

    无需过多关注

    回收方式

    父进程调用wait/waitpid回收

    被子进程寻找的新父进程收养并回收

    可用ps或top观察进程状态。

    9. 进程回收:wait与waitpid

    pid_t wait(int *status);

    功能:阻塞等待任意子进程退出并回收该进程的状态,只能由父进程调用。

    • status:子进程退出时的状态,不关心就传NULL;
    • 返回值:成功返回回收的子进程pid,失败返回-1。

    常用状态宏:

    宏

    作用

    WIFEXITED(status)

    判断子进程是否正常结束

    WEXITSTATUS(status)

    获取正常结束时的返回值

    WIFSIGNALED(status)

    判断是否被信号终止

    WTERMSIG(status)

    若是信号终止,获取信号编号

    pid_t waitpid(pid_t pid, int *status, int options);

    waitpid(-1, status, 0) 等价于 wait(status)。

    • pid:-1表示所有子进程;
    • options:0表示阻塞等待;WNOHANG表示非阻塞回收,函数不等待,需要判断返回值决定是否回收成功(通常外面套死循环反复尝试);
    • 返回值:成功返回回收的子进程pid;0表示子进程尚未退出,需下次再试;-1表示错误。

    10. exec族函数

    功能:执行本机上的任意一个可执行文件。一般与fork搭配,在子进程中执行exec。执行exec后,进程的代码段被新程序替换,新程序执行结束则整个进程结束。

    命名规则:

    • l(list):参数一个个列出;
    • v(vector):参数存放在数组中;
    • p(PATH):在系统环境变量PATH中查找文件。

    int execl(const char *path, const char *arg, …);

    int execlp(const char *file, const char *arg, …);

    int execv(const char *path, char *const argv[]);

    int execvp(const char *file, char *const argv[]);

    • path:新程序的路径+文件名;
    • file:新程序文件名(默认在PATH中查找);
    • arg/argv:新程序的参数列表,最后一个参数必须是NULL。

    需要调用自己编译的可执行程序时,第一个参数按"路径+文件名"填写即可。

    int system(const char *command);

    功能:执行一个shell命令。注意不能是需要修改父进程状态的命令,多为信息输出、文件相关操作。内部实现是fork+exec。

    11. 工作目录函数

    char *getcwd(char *buf, size_t size);   /* 获取当前工作路径 */

    int chdir(const char *path);            /* 改变当前工作路径,成功0,失败-1 */

    • buf:存储路径的字符数组;size:数组最大长度;
    • getcwd返回指向buf的指针,失败返回NULL。

    四、线程

    1. 线程概念

    Linux中,线程是轻量级的进程,线程属于某个进程。进程内线程与线程是平级关系,进程默认有一个主线程。

    线程与进程的区别:

    对比项

    进程

    线程

    资源

    资源独立,最小资源分配单位

    共享进程资源,最小执行单位

    稳定性

    相对稳定

    不稳定:多线程中一个崩溃,整个进程崩溃

    创建开销

    需创建约3GB空间

    在进程空间中开辟一个新栈区(8MB)

    并发度

    较低

    高于进程

    每个线程有各自的栈区,这个区域是独立的。

    2. 线程编程步骤

    创建多线程 -> 线程空间操作 -> 线程资源回收(线程退出后默认栈区不释放)。

    查看线程信息的命令:

    ps -eLo pid,ppid,lwp,stat,comm

    ps -eLf

    3. 线程相关函数

    int pthread_create(pthread_t *thread, const pthread_attr_t *attr,

                       void *(*start_routine)(void *), void *arg);

    功能:创建一个指定线程。

    • thread:线程id,预先定义,由该函数返回;
    • attr:线程属性,一般NULL表示默认属性;
    • start_routine:函数指针,线程的执行空间(回调函数);
    • arg:传给回调函数的参数;
    • 返回值:成功0,失败返回错误码。

    pthread_t pthread_self(void);   /* 获取当前线程id,打印用%lu */

    void pthread_exit(void *retval);

    功能:子线程自行退出。retval是退出时的返回状态("临终遗言")。

    int pthread_cancel(pthread_t thread);

    功能:请求结束一个线程。成功0,失败非0。

    int pthread_join(pthread_t thread, void **retval);

    功能:回收指定线程的资源,具有阻塞等待功能:线程没结束,回收线程就阻塞。

    • thread:要回收的子线程tid;
    • retval:回收的返回值,对应pthread_exit传入的值;
    • 返回值:成功0,失败非0值。

    int pthread_detach(pthread_t thread);

    分离属性:设置了分离属性的线程退出后,栈区资源由系统自动回收,创建者不能再join回收。

    4. 互斥锁

    概念:在多线程中对临界资源的排他性访问。临界资源是多线程中会被读写的资源(全局变量、文件、设备),排他访问指同一时刻只能有一个线程操作它。

    为什么需要:线程并发运行时代码穿插执行。以A++为例,它翻译成汇编至少3句,若线程A执行了前2句就被切换到线程B,执行完再切回来,就会产生数据一致性问题。解决方案是互斥。

    使用步骤:定义互斥锁 -> 初始化锁 -> 加锁 -> 解锁 -> 销毁。

    pthread_mutex_t mutex;    /* 1.定义 */

    int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);

    /* 2.初始化:mutex要初始化的锁;attr一般NULL默认锁;成功0,失败非零 */

    int pthread_mutex_lock(pthread_mutex_t *mutex);

    /* 3.加锁:加锁到解锁之间的代码构成原子操作,其他线程不能操作;

       锁被占用时本函数阻塞;成功0,失败非零 */

    int pthread_mutex_unlock(pthread_mutex_t *mutex);

    /* 4.解锁:解锁后代码不再排他访问,一般加锁解锁成对出现;成功0,失败非零 */

    int pthread_mutex_destroy(pthread_mutex_t *mutex);

    /* 5.销毁:使用完毕后销毁互斥锁;成功0,失败非零 */

    原子操作:在线程的一次调度中必须完整执行、不能发生线程切换的代码段。

    5. 同步与信号量

    概念:有一定先后顺序的对资源的排他性访问。互斥包含同步,同步是互斥的一个特例。

    与互斥锁的区别(互斥包含同步,同步是互斥的一个特例):

    对比项

    互斥锁

    信号量(同步)

    加锁/释放关系

    加锁和解锁是同一个线程

    交叉释放:th1释放th2,th2释放th1

    临界区要求

    不要休眠、不要大耗时操作,代码短小精悍

    可适当休眠、小的耗时操作

    初值

    二值(0/1)

    计数信号量,初值可大于1(如3、5),用于资源数不唯一的情况

    使用步骤:信号量定义 -> 初始化 -> PV操作 -> 销毁。

    sem_t sem;    /* 1.定义 */

    int sem_init(sem_t *sem, int pshared, unsigned int value);

    /* 2.初始化:pshared为0表示线程间使用;非0表示进程间使用;

       value信号量初值,无名信号量一般是0/1二值信号量;

       0表示红灯(阻塞),1表示绿灯(通过);成功0,失败-1 */

    int sem_wait(sem_t *sem);

    /* 3.P操作(申请资源):sem有资源(==1)则申请并继续,sem为0则阻塞等待;

       申请后自动执行 sem = sem – 1;成功0,失败-1 */

    int sem_post(sem_t *sem);

    /* 3.V操作(释放资源):释放信号量,自动执行 sem = sem + 1;

       不会阻塞;成功0,失败-1 */

    int sem_destroy(sem_t *sem);

    /* 4.销毁:成功0,失败-1 */

    6. 死锁

    概念:由于锁资源安排不合理(锁的申请和释放逻辑不对),导致进程/线程无法正常继续推进的现象。

    产生死锁的四个必要条件:

  • 互斥条件:一个资源每次只能被一个进程使用;
  • 请求与保持条件:进程因请求资源而阻塞时,对已获得的资源保持不放;
  • 不剥夺条件:进程已获得的资源,在未使用完之前不能被强行剥夺;
  • 循环等待条件:若干进程之间形成头尾相接的循环等待资源关系。
  • 五、IPC进程间通信

    1. 为什么需要IPC

    进程空间都是独立的(虚拟内存隔离),但进程之间有数据共享或交换的需求,所以需要IPC(interprocess communication)。

    2. IPC分类

    类别

    典型方式

    说明

    古老的通信方式

    无名管道、有名管道、信号

    简单、历史长

    IPC对象通信(System V)

    消息队列、共享内存、信号量集

    通过key/id管理

    socket通信

    网络通信

    不同主机间交互

    六、无名管道(pipe)

    1. 概念

    无名管道通过pipe()创建,只能给有亲缘关系的进程(典型:fork出的父子进程)通信。管道的底层实现是队列。

    2. 特点

    • 管道是半双工工作模式,实际编程中当作单工使用;
    • 所有管道都是特殊文件,不支持定位操作(lseek/fseek);
    • 读写首选文件IO:open、read、write、close(标准IO带缓冲区也可选)。

    3. 阻塞规则

    情况

    结果

    读端存在,一直写超过64K

    写阻塞

    写端存在,管道为空时读

    读阻塞

    读端关闭,继续写

    管道破裂,写进程退出

    写端关闭且管道无内容

    read返回0,通信结束

    4. 接口

    int pipe(int pipefd[2]);

    • pipefd[0]:无名管道的固定读端;
    • pipefd[1]:无名管道的固定写端;
    • 返回值:成功0,失败-1。

    5. 编程顺序

    创建管道 -> fork -> 父子进程各自读写管道 -> 关闭管道

    6. 示例框架

    #include <stdio.h>

    #include <string.h>

    #include <unistd.h>

    #include <sys/wait.h>

    int main(void)

    {

        int fd[2];

        pipe(fd);

        pid_t pid = fork();

        if (pid == 0) {                      /* 子进程:读 */

            close(fd[1]);

            char buf[128] = {0};

            read(fd[0], buf, sizeof(buf) – 1);

            printf("child: %s\\n", buf);

            close(fd[0]);

        } else if (pid > 0) {                /* 父进程:写 */

            close(fd[0]);

            const char *msg = "hello pipe";

            write(fd[1], msg, strlen(msg));

            close(fd[1]);

            wait(NULL);

        } else {

            perror("fork");

            return 1;

        }

        return 0;

    }

    七、有名管道(FIFO)

    1. 概念

    有名管道通过mkfifo()创建,可以给任意单机进程通信,有文件名称,在文件系统中可见。

    2. 特性

    • 与匿名管道特性一致(半双工、不支持定位、阻塞规则相同);
    • 额外特性:如果有一端没有打开,另一端默认在open函数处阻塞。

    3. 编程顺序

    创建有名管道 -> 打开有名管道 -> 读写管道 -> 关闭管道 -> 卸载有名管道

    4. 接口

    int mkfifo(const char *pathname, mode_t mode);

    • pathname:要创建的有名管道路径+名称;
    • mode:8进制文件权限(如0666);
    • 返回值:成功0,失败-1。

    示例:

    mkfifo("/tmp/myfifo", 0666);

    int fd = open("/tmp/myfifo", O_RDONLY);

    read(fd, buf, sizeof(buf));

    close(fd);

    unlink("/tmp/myfifo");

    八、信号通信

    1. 概念

    信号是异步通知机制,用于告知进程发生了某个事件。应用场景:用户按键(Ctrl+C)、定时器超时、子进程结束、非法内存访问、其他进程主动通知等。

    2. 信号发送和接收的过程

  • 根据需要(随机事件)发生信号;
  • Linux接收到信号发生请求,在PCB链表中查找对应的pid;
  • 找到对应进程后,使原工作流程先暂停,执行PCB中信号处理函数表对应下标的函数(发信号2就调用handle2);
  • 处理函数执行完毕后,原来的代码继续运行。
  • 3. 信号相关函数

    int kill(pid_t pid, int sig);

    功能:给pid进程发送编号为sig的信号。信号编号可用kill -l查看。成功0,失败-1。

    sighandler_t signal(int signum, sighandler_t handler);

    功能:设置信号的处理方式。handler有三种取值:

    • SIG_DFL:默认处理;
    • SIG_IGN:忽略处理;
    • 自定义函数:信号到达时执行。

    注意:SIGKILL和SIGSTOP不能被捕获、阻塞或忽略。

    九、共享内存

    1. 概念

    共享内存是System V提供的一种IPC方式:把同一块物理内存映射到多个进程的虚拟地址空间,双方直接读写,是速度最快的IPC之一。

    2. 特点

    • 通信双方都可以读写;
    • 没有读阻塞、没有写阻塞,必须搭配其他IPC(信号或信号量集)做同步;
    • 数据不删除就一直保持(像一块公共字符数组);
    • 空间可自定义(4K×n),适合大块数据。

    3. 使用流程

    生成key -> 申请共享内存 -> 映射共享内存 -> 读写共享内存 -> 撤销映射 -> 删除共享内存

    4. 接口

    key_t ftok(const char *pathname, int proj_id);

    功能:用pathname指定路径和proj_id生成唯一键值。pathname可以是任意文件,只要不会被删除重建即可;proj_id一般用ASCII单字符。成功返回唯一键值,失败-1。

    int shmget(key_t key, size_t size, int shmflg);

    功能:用key向内核申请共享内存。

    • key:ftok生成的键值;
    • size:申请大小(字节);
    • shmflg:权限(八进制),第一个申请加IPC_CREAT,检测是否已存在用IPC_EXCL;
    • 返回值:成功返回共享内存id(shmid),失败-1。

    void *shmat(int shmid, const void *shmaddr, int shmflg);

    功能:将shmid对应的共享内存映射到本地。

    • shmaddr:本地可用地址,不确定传NULL由系统分配;
    • shmflg:0读写;SHM_RDONLY只读;
    • 返回值:成功返回映射地址,失败(void*)-1。

    int shmdt(const void *shmaddr);          /* 撤销映射:成功0,失败-1 */

    int shmctl(int shmid, int cmd, struct shmid_ds *buf);

    /* cmd为IPC_RMID时删除共享内存对象,buf传NULL;成功0,失败-1 */

    读写方式:二进制对象用memcpy(),字符串对象用strcpy()。

    总结:shmget/shmat/shmdt都不删除共享内存,只有shmctl(IPC_RMID)标记删除。

    5. 与管道的区别

    对比项

    管道

    共享内存

    数据形式

    字节流

    普通内存区域

    读写方向

    通常单向

    双方均可读写

    阻塞

    可能读/写阻塞

    不阻塞

    速度

    需经过内核缓冲区

    直接访问,速度快

    数据保留

    读走后消失

    删除对象前一直存在

    同步

    管道自身有一定协调

    必须额外同步

    6. 相关命令

    ipcs -a        /* 查询共享内存、信号量集、消息队列 */

    ipcrm -m shmid /* 删除共享内存 */

    十、对比总结

    机制

    适用关系

    方向

    阻塞特性

    同步方式

    无名管道

    亲缘进程

    半双工(当单工用)

    读写均可阻塞

    read返回0判结束

    有名管道

    任意单机进程

    半双工(当单工用)

    open处可能阻塞

    同无名管道

    信号

    任意进程

    单向通知

    不阻塞(异步)

    signal自定义处理

    共享内存

    任意进程

    双向读写

    完全不阻塞

    须搭配信号/信号量集

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 进程线程与通信
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!