1.进程与线程
1.1 进程
进程是一个正在执行的程序实例,包括程序代码及其运行时所需的所有资源(如内存、文件句柄等)。它是资源分配和管理的基本单位。进程具有以下特性:
- 独立性:每个进程拥有自己的地址空间和资源,不同进程之间互不干扰。
- 资源拥有:进程拥有自己的内存、文件描述符、信号处理等资源。
- 开销较大:由于进程间需要隔离,其切换(上下文切换)开销较大,涉及到保存和恢复CPU寄存器、内存映射等。
1.2 线程
线程是进程中的一个执行单元,Linux将线程视为轻量级进程(LWP)。一个进程可以包含多个线程,线程共享进程的资源,但可以独立执行。线程具有以下特性:
- 共享资源:同一进程中的线程共享内存地址空间和其他资源(如文件句柄),这使得线程间通信更高效。
- 独立调度:每个线程有自己的程序计数器(PC)、寄存器集和栈,但与同一进程内的其他线程共享数据段和堆。
- 开销较小:线程的创建和上下文切换比进程更轻量,开销更小。
2.进程状态
在Linux操作系统中,进程的生命周期由不同的状态组成。每个进程在其生命周期中都会经历多个状态,操作系统通过这些状态来管理和调度进程。
2.1 进程的创建
在Linux系统中,进程的创建主要通过以下几个系统调用实现:fork()、vfork()、exec()和clone()。
2.2.1 父进程与子进程
父进程是调用进程创建新进程的原始进程,通过调用系统调用(如fork())生成一个新的子进程,拥有子进程的进程ID(PID),并且可以通过wait()或waitpid()系统调用等待子进程的终止以获取其退出状态。
子进程由父进程创建,是父进程的副本,继承了父进程的大部分属性。
拥有自己独立的进程ID(PID),但共享父进程的一些资源(如文件描述符)。可以执行与父进程相同的代码,也可以使用exec()系统调用加载并执行新的程序。
2.1.2 fork()
fork()是最常用的创建新进程的系统调用。调用fork()时,操作系统会创建一个与调用进程(父进程)几乎完全相同的新进程(子进程)。子进程获得父进程的一个副本,包括代码段、数据段、堆、栈等。
fork()的工作机制
1.复制进程控制块(PCB):内核为子进程创建一个新的进程控制块,并复制父进程的进程控制块内容。
2.复制地址空间:内核为子进程分配新的地址空间,并将父进程的地址空间内容复制到子进程的地址空间中。现代操作系统通常采用写时复制(Copy-On-Write, COW)技术,推迟实际的内存复制,直到父子进程之一试图写入这段内存。
3.返回值:fork()在父进程中返回子进程的进程ID(PID),在子进程中返回0。
代码示例
#include <stdio.h>
#include<unistd.h> //包含UNIX标准头文件,用于使用fork和getpid函数
int main()
{
pid_t pid = fork(); //创建一个新进程(子进程),fork函数返回子进程的PID;如果是子进程,返回0;如果失败,返回-1
if (pid < 0)
{
//pid小于0,fork调用失败
perror("fork failed"); //输出错误到标准错误流
return 1; //程序异常退出
}
else if (pid == 0)
{
//pid等于0,表示在子进程中
printf("This is the child process,PID:%d\n", getpid());
}
else
{
//pid大于0,说明在父进程中
printf("This is the parent process,PID:%d\n", getpid());
}
return 0;
}
pid_t 是 POSIX 标准定义的数据类型,用于表示进程ID。它通常是一个整数类型。
2.1.3 vfork()
vfork()类似于fork(),但更高效,因为它不复制父进程的地址空间,而是与父进程共享地址空间,直到子进程调用exec()或退出。
- 共享地址空间:子进程直接使用父进程的地址空间。
- 父进程挂起:在子进程调用exec()或退出之前,父进程被挂起。
- 使用注意:子进程在调用exec()或退出之前,不应修改父进程的地址空间。
2.1.4 exec()家族
exec()家族函数用于将当前进程的地址空间替换为一个新程序的地址空间。它不创建新进程,而是用新程序覆盖当前进程。这个家族包括多个不同的函数,每个函数提供不同的参数传递方式和功能。
execl():接受可变数量的参数列表
#include <unistd.h>
int execl(const char *path, const char *arg0, ..., (char *)0);
execv():接受参数数组。
#include <unistd.h>
int execv(const char *path, char *const argv[]);
execle():接受参数列表和环境变量。
#include <unistd.h>
int execle(const char *path, const char *arg0, ..., (char *)0, char *const envp[]);
execve():接受参数数组和环境变量。
#include <unistd.h>
int execve(const char *path, char *const argv[], char *const envp[]);
execlp():与execl()类似,但搜索系统路径以查找可执行文件。
#include <unistd.h>
int execlp(const char *file, const char *arg0, ..., (char *)0);
execvp():与execv()类似,但搜索系统路径以查找可执行文件。
#include <unistd.h>
int execvp(const char *file, char *const argv[]);
这些函数的共同点是,它们都用于执行一个新的程序,用新的程序覆盖当前进程的地址空间。它们之间的区别主要在于参数传递方式和环境变量的处理。
下面对其中一些参数进行解释:
- exec()函数会在调用成功时返回,否则不会返回,而是返回-1并设置errno以指示错误类型。
- 参数path表示要执行的程序的路径或文件名。
- 参数arg0是新程序的名称(在execl()和execlp()中是第一个参数,在execle()中是第二个参数),通常与path相同。
- argv参数是一个指向空字符结尾的字符串数组,用于传递命令行参数给新程序。
- envp参数是一个指向空字符结尾的字符串数组,用于传递环境变量给新程序。
这些函数的命名约定通常是:
- l 表示参数以单个字符串形式传递。
- v 表示参数以数组形式传递。
- e 表示还传递了环境变量。
在调用exec()函数之后,当前进程的地址空间被替换为新程序的地址空间,新程序开始执行。原来的程序代码、数据等全部被替换。这使得exec()函数成为了创建新进程并加载新程序的重要方法之一。
下面是一个完整的代码示例,通过使用了 execvp 函数来执行 /bin/ls 命令(列出当前目录下的所有文件)
#include<stdio.h>
#include<unistd.h>
int main()
{
char* args[] = { "/bin/ls",NULL }; //声明一个字符串数组,存储命令及其参数
execvp(args[0], args); //执行ls命令(列出当前目录)
//若execvp返回,则说明执行失败
perror("execvp failed");
return 1;
}
编译运行结果如下
2.1.5 clone()
clone() 是一个灵活且强大的系统调用,用于创建新进程或线程,并允许精细控制新进程或线程与父进程共享的资源。它与 fork() 和 vfork() 相比,提供了更多的选项来定制新创建的任务行为。通过传递不同的标志,可以指定新进程与父进程共享哪些资源,例如内存空间、文件描述符表、信号处理等。clone() 是 Linux 系统调用,用于实现线程库和轻量级进程。
clone()函数原型
#include <sched.h>
#include <signal.h>
#include <unistd.h>
int clone(int (*fn)(void *), void *child_stack, int flags, void *arg, ...);
- fn:新进程将执行的函数指针。
- child_stack:指向子进程堆栈的指针(栈顶地址)。
- flags:标志,用于控制子进程和父进程之间的共享行为。
- arg:传递给 fn 的参数。
- 变长参数(可选):指定子进程的 pid_t 地址、thread ID 地址等。
常用标志(flags)
标志 | 说明 |
---|---|
CLONE_VM | 父子进程共享同一个内存空间。如果设置了此标志,子进程将与父进程共享同一个地址空间。 |
CLONE_FS | 父子进程共享文件系统信息(当前工作目录、根目录等)。 |
CLONE_FILES | 父子进程共享文件描述符表。 |
CLONE_SIGHAND | 父子进程共享信号处理表。 |
CLONE_THREAD | 将子进程作为同一线程组中的线程。 |
CLONE_PARENT | 子进程的父进程变为调用进程的父进程。 |
CLONE_CHILD_CLEARTID | 用于线程同步,子进程结束时清除 TID。 |
CLONE_CHILD_SETTID | 用于线程同步,子进程结束时设置 TID。 |
代码示例
下面是一个创建新进程的完整代码示例:
# define _GNU_SOURCE //启用GNU扩展
#include<sched.h>
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/wait.h>
#define STACK_SIZE (1024*1024) //定义栈大小为1MB
int child_function(void* arg)
{
printf("Child process PID:%d\n", getpid());
return 0;
}
int main()
{
char* stack = malloc(STACK_SIZE); //尝试动态分配1MB的内存作为子进程的栈
//若分配失败,malloc会返回NULL
if (!stack)
{
perror("malloc");
exit(EXIT_FAILURE); //执行失败,退出程序并返回失败状态码
}
pid_t pid = clone(child_function, stack + STACK_SIZE, CLONE_VM | CLONE_FS | CLONE_FILES | SIGCHILD, NULL);
//进程创建失败
if (pid == -1)
{
perror("clone"); //打印错误
free(stack); //释放栈内存
exit(EXIT_FAILURE);
}
printf("Parent process PID:%d,Child PID:%d\n", getpid(), pid);
wait(NULL); //等待子进程结束
free(stack);
return 0;
}
clone() 是多线程库(如 pthread)的基础,它允许精细控制线程间的资源共享。例如,pthread_create() 就是基于 clone() 实现的,设置了适当的标志以确保线程之间共享内存空间和文件描述符等资源。理解 clone() 的工作机制和使用场景,可以有效地实现高效的多任务处理和并发编程。
2.2 就绪状态
进程的就绪状态是指进程已经准备好执行,但由于当前没有可用的CPU资源,所以暂时无法运行。在Linux系统中,就绪状态的进程被放置在就绪队列中,等待被调度器选中执行。 进程的调度就是操作系统的调度器负责选择就绪队列中的进程,并将其分配给可用的CPU的过程。
2.2.1 进程调度的目标
- 公平性:每个进程都有公平的机会获得CPU资源,避免饥饿现象。
- 响应性:及时响应用户的请求,提高系统的交互性。
- 高效性:尽可能地提高CPU的利用率,减少空闲时间。
- 预测性:尽可能减少进程的等待时间,提高整体性能。
2.2.2 常见的调度算法
- 先来先服务(First-Come, First-Served, FCFS):按照进程到达的顺序分配CPU资源,缺点是平均等待时间较长,不适合交互式应用。
- 最短作业优先(Shortest Job First, SJF):选择预计执行时间最短的进程优先执行,该算法可以最小化平均等待时间,但需要知道进程的执行时间。
- 最短剩余时间优先(Shortest Remaining Time First, SRTF):在SJF的基础上,动态调整进程的执行顺序,根据当前剩余执行时间来调度,该算法更加灵活并且响应性强,但增加了调度开销。
- 轮转调度(Round Robin):每个进程被分配一个时间片(时间量),按照轮转的方式执行,当时间片用完或者进程被阻塞时,调度器将切换到下一个就绪态进程。时间片大小影响系统的响应性和CPU利用率。
- 多级反馈队列调度(Multilevel Feedback Queue):将进程分成多个队列,每个队列拥有不同的优先级,通常按照时间片轮转的方式调度。进程在队列之间转移,根据运行时间和等待时间调整优先级。
2.3 运行状态
进程的运行状态是指进程正在CPU上执行其代码。在这个状态下,进程的指令被CPU执行,它正在完成其分配的任务。
2.3.1 运行状态的转换
- 进程创建:新创建的进程首先进入运行状态,被调度器选中执行。
- 时间片用完:如果进程之前处于运行状态,当它的时间片用完时,进程将被放置回就绪队列,等待下一次调度。
- 等待事件完成:当进程需要等待某些事件(如I/O操作)完成时,它将从运行状态转换到阻塞状态或挂起状态,直到事件完成后重新进入运行状态。
- 被抢占:如果有更高优先级的进程需要执行,当前运行的进程可能会被抢占,放置回就绪队列,等待下一次调度。
2.4 进程的阻塞 / 等待状态
阻塞(Blocked)或等待(Waiting)状态是指进程由于等待某些事件的发生而暂时无法执行。在这个状态下,进程处于睡眠状态,通常被放置在睡眠队列中,不会消耗CPU资源,直到所等待的事件发生为止。
2.4.1 进程阻塞/等待状态的转换
- I/O操作:当进程发起一个I/O操作(如读取文件、网络通信等),它会进入阻塞/等待状态,直到I/O操作完成。
- 信号等待:当进程等待一个信号(如SIGTERM、SIGINT)时,它也会进入阻塞/等待状态,直到信号被触发。
- 资源等待:当进程等待某些系统资源(如锁、信号量)释放时,它也会进入阻塞/等待状态。
- 进程同步:当进程需要等待其他进程的某些操作完成(如子进程的终止)时,它也会进入阻塞/等待状态。
2.4.2 阻塞/等待状态的管理
- 事件通知:一旦所等待的事件发生,操作系统会通知进程,将其从阻塞状态转换为就绪状态,准备执行。
- 超时处理:某些情况下,进程可能会设置超时时间,如果等待时间过长,系统会将其从阻塞状态转换为就绪状态,以便进行其他操作。
2.5 挂起状态
挂起(Suspended),有时也称为“暂停”(Stopped)状态。进程在阻塞状态或运行状态时,可以被操作系统或用户挂起。挂起的进程将其内存转移到磁盘,并停止执行。挂起操作通常用于释放内存资源。挂起状态的进程可以被恢复到就绪状态。
2.5.1 进程挂起状态的转换
- 手动挂起:操作系统提供了手动挂起进程的功能,例如管理员可以通过特定命令将进程挂起,暂时停止其执行。
- 资源不足:当系统资源不足时,操作系统可能会自动将一些进程挂起,以释放资源给更高优先级的进程。
- 等待唤醒事件:有些情况下,进程可能需要等待某些事件的发生,然后挂起等待直到事件发生。
2.5.2 系统资源管理
- 释放资源:挂起状态的进程释放了它所占用的系统资源,例如CPU时间、内存等,使得这些资源可以被其他进程使用。
- 资源回收:挂起状态的进程不再占用系统资源,因此可以更轻松地回收它所占用的资源。
2.5.3 挂起状态的管理
- 唤醒:当挂起的进程需要继续执行时,操作系统可以将其唤醒,使其恢复到就绪状态,等待下一次调度执行。
- 手动唤醒:管理员或其他进程可以手动唤醒挂起的进程,以便其继续执行。
2.6 终止 / 退出状态
当进程完成其执行或被强制终止时,它进入终止状态,终止状态的进程不再执行,操作系统将回收其所占用的资源。
2.6.1 进程终止/退出状态的转换
- 正常退出:进程完成了它的主要任务,通过调用exit()系统调用或返回main()函数,进程自愿地终止。
- 异常退出:进程遇到了致命错误,如除以零、访问非法内存等,被操作系统强制终止。
- 被其他进程终止:父进程调用了wait()或waitpid()等系统调用,等待子进程结束,子进程因此被终止。
2.6.2 系统资源管理
- 资源回收:在进程终止状态下,操作系统回收了进程所占用的所有资源,包括内存、文件描述符、打开的文件等。
- 关闭打开的资源:操作系统会关闭进程打开的文件、网络连接等资源,以释放系统资源。
2.6.3 终止状态的管理
- 进程回收:进程终止后,操作系统会保留一段时间以供父进程查看退出状态,然后彻底清除进程控制块和相关信息。
- 子进程回收:父进程需要调用wait()或waitpid()等系统调用来回收已经终止的子进程,以防止它们变成僵尸进程。
2.7 其他进程状态
2.7.1 不可中断睡眠状态
不可中断睡眠状态(Uninterruptible Sleep)表示进程在等待某些事件完成时无法被中断。在这个状态下,进程正在等待的事件通常是无法被取消或中断的,如设备I/O操作,由于等待的事件无法被中断,进程可能会在不可中断睡眠状态下持续较长时间。
2.7.1.1 进程进入不可中断睡眠状态的情况
- 设备I/O操作:当进程等待硬件设备(如磁盘、网络接口)完成I/O操作时,它可能会进入不可中断睡眠状态。
- 文件系统操作:当进程需要访问文件系统并且操作需要与底层存储设备交互时,可能会进入不可中断睡眠状态。
- 等待锁:在多线程环境下,当进程等待锁的释放时,如果锁的持有者进入睡眠状态,可能会导致进程进入不可中断睡眠状态。
2.7.1.2 不可中断睡眠状态的解除
- 事件完成:当进程等待的事件完成时,如I/O操作完成或锁释放,进程会从不可中断睡眠状态中解除。
- 被信号中断:虽然称为不可中断睡眠状态,但一些情况下进程仍然可以被一些特殊信号(如硬件中断)中断。
2.7.2 停止状态
停止状态(Stopped)表示进程已暂停执行,通常是由于接收到某些特定的信号而进入该状态。在这个状态下,进程不会继续执行,但它仍然存在于系统中,可以随时被唤醒继续执行。
2.7.2.1 进入停止状态的情况
- 接收到停止信号:进程接收到特定的停止信号,如SIGSTOP、SIGTSTP等,导致进程暂停执行并进入停止状态。
- 调试器操作:调试器(如gdb)可以通过发送停止信号来暂停目标进程的执行,以便进行调试。
2.7.2.2 停止状态的解除
- 接收到继续信号:当进程接收到继续信号(如SIGCONT)时,它会解除停止状态,恢复到就绪状态,等待被调度执行。
- 被调试器操作:调试器可以通过发送继续信号来解除进程的停止状态,使其恢复执行。
2.7.3 僵尸状态
僵尸状态(Zombie)表示进程已经终止执行,但其父进程尚未调用wait()或waitpid()等系统调用来获取其退出状态。在这个状态下,进程已经完成了它的任务,但是其进程描述符仍然存在于系统中,直到其父进程回收了它的资源才会被完全清除。
2.7.3.1 进程进入僵尸状态的情况
- 进程终止:进程完成了其任务,但其父进程尚未调用wait()或waitpid()等系统调用来获取其退出状态。
- 父进程忽略了子进程终止信号:父进程未注册对子进程终止信号的处理程序,导致子进程变成僵尸进程。
2.7.3.2 解决僵尸进程问题
- 父进程回收:父进程应当调用wait()或waitpid()等系统调用来回收已终止子进程的资源,防止其成为僵尸进程。
- 信号处理:父进程可以注册对SIGCHLD信号的处理程序,在子进程终止时及时处理。
3.常见进程之间的相互转换
进程状态之间的转换由操作系统的调度器和事件驱动。以下是一些常见的状态转换。
- 创建 → 就绪:进程创建完成后,进入就绪状态,等待CPU分配。
- 就绪 → 运行:调度器选择一个就绪进程并分配CPU。
- 运行 → 就绪:进程的时间片用完,操作系统将其切换回就绪状态。
- 运行 → 阻塞:进程等待某些事件发生(如I/O操作),进入阻塞状态。
- 阻塞 → 就绪:等待的事件发生后,进程从阻塞状态回到就绪状态。
- 运行 → 终止:进程完成其任务或被杀死,进入终止状态。
- 就绪/阻塞 → 挂起:系统内存不足或用户要求,进程被挂起。
- 挂起 → 就绪/阻塞:挂起的进程被恢复,返回原先的状态。
4.进程间的通信(IPC)
进程间通信(Inter-Process Communication,IPC)是不同进程之间进行数据交换和同步的一种机制。在多任务操作系统中,进程间通信允许进程之间共享信息、传递数据和协调活动。操作系统提供了多种通信机制,如管道、信号、消息队列、共享内存和套接字等。
4.1 管道通信
管道(Pipe)是一种进程间通信的简单机制,主要用于在具有父子关系的进程之间进行通信。管道可以是无名管道(Anonymous Pipe)或有名管道(Named Pipe)。
4.1.1 无名管道
4.1.1.1 创建管道
使用pipe()系统调用创建管道,它会返回两个文件描述符,一个用于读取数据,一个用于写入数据。管道是半双工的,数据只能单向流动!
#include <unistd.h>
int pipe(int pipefd[2]);
4.1.1.2 写入数据
父进程将数据写入管道的写入端,子进程从管道的读取端读取数据。
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
4.1.1.3 读取数据
子进程从管道的读取端读取数据。
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
4.1.1.4 关闭文件描述符
读取完数据后,需要关闭读取端文件描述符,写入完数据后,需要关闭写入端文件描述符。
#include <unistd.h>
int close(int fd);
4.1.4.5 完整示例
下面是一个完整的代码示例,创建了一个父子进程,父进程向管道中写入数据,子进程从管道中读取数据,并通过管道,父子进程完成了数据交换。
#include<stdio.h>
#include<unistd.h>
#include<string.h>
#include<sys/wait.h>
int main()
{
int pipefd[2]; //声明数组,保存管道的两个文件描述符
pid_t pid; //保存进程ID
char buffer[20]; //读取数据缓冲区
//创建管道,若失败,返回错误信息并退出
if (pipe(pipefd) == -1)
{
perror("pipe");
return 1;
}
//创建子进程
pid = fork();
if (pid == -1)
{
perror("fork");
return 1;
}
if (pid == 0)
{
close(pipefd[1]); //关闭写入端
//从管道读取数据
read(pipefd[0], buffer, sizeof(buffer));
printf("Child received:%s\n", buffer);
close(pipefd[0]); //关闭读取端
}
else
{
//pid>0,表示在父进程中
close(pipefd[0]); //关闭读取端
const char* msg = "Hello,child!"; //定义要发送的数据
//写入数据到管道
write(pipefd[1], msg, strlen(msg) + 1);
close(pipefd[1]); //关闭写入端
wait(NULL); //等待子程序退出
}
return 0;
}
4.1.2 有名管道
有名管道(Named Pipe),也称为FIFO(First In, First Out),是一种特殊类型的文件,它允许无关进程之间进行通信。有名管道存在于文件系统中,可以通过文件路径进行访问,因此即使没有父子关系的进程也可以进行通信。
4.1.2.1 创建管道
使用mkfifo()系统调用创建有名管道。在创建时,需要指定路径和权限。
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
- pathname:管道的路径名。
- mode:文件权限(如0666表示读写权限)。
4.1.2.2 打开和使用有名管道
进程可以使用open()系统调用打开有名管道,然后使用read()和write()进行通信。需包含fcntl.h
头文件
4.1.2.3 写入与读取数据
有名管道的写入与读取数据方式与无名管道类似,请参考前一节无名管道的数据写入与读取,此处不赘述。
4.1.2.4 使用有名管道的注意事项
- 命名空间:有名管道存在于文件系统中,因此需要确保路径的唯一性,避免命名冲突。
- 权限管理:创建有名管道时,可以指定访问权限,确保只有授权的进程能够读写管道。
- 阻塞行为:默认情况下,打开管道的读写操作是阻塞的。打开写端而没有任何读端,或者反之,可能会导致进程阻塞或收到错误。
- 清理管道:使用完有名管道后,应该删除它以释放资源,可以使用unlink()函数删除有名管道文件。示例如下:
#include <unistd.h>
int unlink(const char *pathname);
4.1.2.5 完整代码及测试
不同于无名管道只适用于父子进程之间的通信,有名管道可以用于任何进程之间的通信,因为它们不需要共享父子关系,只需知道管道文件的位置即可。因此我们可以写三个独立的程序分别在三个不同的终端进行运行测试。
创建有名管道
//create_fifo.c
#include<stdio.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<errno.h>
int main()
{
const char* fifo_path = "/home/vonphy/my_fifo"; //定义有名管道路径
//创建有名管道
if (mkfifo(fifo_path, 0666) == -1)
{
//错误码不是EEXIST(文件已存在),则返回错误消息
if (errno != EEXIST)
{
perror("mkfifo");
return 1;
}
}
printf("Named pipe created at %s\n", fifo_path);
return 0;
}
打开第一个终端,执行以下命名编译运行:
gcc create_fifo.c -o creatr_fifo
./create_fifo
写入数据
//write_fifo.c
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
#include<stdio.h>
int main()
{
const char* fifo_path = "/home/vonphy/my_fifo"; //指定文件路径
int fd; //定义文件描述符
//打开有名管道写入端
fd = open(fifo_path, O_WRONLY); //以只写方式打开有名管道
//检查open函数是否返回错误
if (fd == -1)
{
perror("open");
return 1;
}
const char* msg = "Hello from writter!";
//写入数据到管道
write(fd, msg, strlen(msg) + 1);
printf("Successful write!");
close(fd);
return 0;
}
打开第二个终端,执行命令:
gcc write_fifo.c -o write_fifo
./write_fifo
此时将等待数据进行读取。
读取数据
//read_fifo.c
#include<fcntl.h>
#include<unistd.h>
#include<stdio.h>
int main()
{
const char* fifo_path = "/home/vonphy/my_fifo";
int fd;
char buffer[100];
//打开有名管道读取端
fd = open(fifo_path, O_RDONLY);
if (fd == -1)
{
perror("open");
return 1;
}
//读取数据
read(fd, buffer, sizeof(buffer));
printf("Reader recevied:%s\n", buffer);
close(fd);
return 0;
}
打开第三个终端,执行命令:
gcc read_fifo.c -o read_fifo
./read_fifo
我们发现已经接收到消息。
删除管道
//delet_fifo.c
#include<unistd.h>
#include<stdio.h>
int main()
{
const char* fifo_path = "/home/vonphy/my_fifo";
//删除有名管道
if (unlink(fifo_path) == -1)
{
perror("unlink");
return 1;
}
printf("Named pipe %s deleted\n", fifo_path);
return 0;
}
最后,我们删除有名管道以释放文件系统资源。
gcc delete_fifo.c -o delete_fifo
./delete_fifo
4.2 信号通信
信号是一种异步的通信机制,用于通知进程发生了某些事件,如程序异常、外部输入等。信号可以在进程之间传递,例如父进程可以向子进程发送信号。常见信号有SIGKILL、SIGTERM、SIGINT等。
4.2.1 信号的基本概念
-
信号的种类
每种信号都有一个唯一的整数标识符和一个预定义的行为。例如,SIGINT 信号(值为2)用于通知进程中断(通常由Ctrl+C产生),SIGTERM 信号(值为15)用于请求进程终止。 -
信号的行为
每个信号都有一个默认的处理行为,比如终止进程、忽略信号或核心转储(core dump)。但是,进程可以通过捕获信号来自定义处理行为。 -
信号的发送
信号可以通过 kill 系统调用或 raise 函数发送。kill 可以向任意进程发送信号,而 raise 只能向调用它的进程发送信号。 -
信号的处理
进程可以通过设置信号处理函数来捕获并处理特定信号。这可以通过 signal 或 sigaction 函数来实现。
4.2.2 常见信号
在Linux系统中,有许多常见的信号,每个信号都有特定的用途和默认行为。以下是一些常见信号的简要说明。
信号 | 用途 | 默认行为 |
---|---|---|
SIGHUP (1) | 挂起信号,通常在终端关闭时发送给控制终端的前台进程组,也用于通知守护进程重新读取配置文件。 | 终止进程。 |
SIGINT (2) | 中断信号,通常由用户在终端按 Ctrl+C 组合键生成,用于中断前台进程。 | 终止进程。 |
SIGQUIT (3) | 退出信号,通常由用户在终端按 Ctrl+\ 组合键生成,生成核心转储。 | 终止进程并生成核心转储。 |
SIGILL (4) | 非法指令,通常在进程试图执行非法、格式不正确或特权级不正确的指令时产生。 | 终止进程并生成核心转储。 |
SIGABRT (6) | 进程调用 abort() 函数生成该信号,用于指示异常终止。 | 终止进程并生成核心转储。 |
SIGFPE (8) | 浮点异常信号,通常在发生浮点运算错误(如除零、溢出等)时产生。 | 终止进程并生成核心转储。 |
SIGKILL (9) | 强制终止进程,不能被捕获、阻塞或忽略。 | 默认行为:立即终止进程。 |
SIGSEGV (11) | 无效内存引用或分段错误,通常在进程试图访问未分配的内存区域时产生。 | 终止进程并生成核心转储。 |
SIGPIPE (13) | 管道破裂信号,通常在写入一个没有读端的管道时产生。 | 终止进程。 |
SIGALRM (14) | 定时器信号,由 alarm() 函数设置的定时器到时产生。 | 终止进程。 |
SIGTERM (15) | 终止信号,用于请求进程正常终止,可以被捕获和处理。 | 终止进程。 |
SIGUSR1 (10) 和 SIGUSR2 (12) | 用户定义信号,用于应用程序自定义用途。 | 终止进程。 |
SIGCHLD (17) | 子进程状态改变信号,通常在子进程退出或停止时产生。 | 忽略。 |
SIGCONT (18) | 继续执行一个停止的进程。 | 继续进程执行。 |
SIGSTOP (19) | 停止进程执行,不能被捕获或忽略。 | 停止进程。 |
SIGTSTP (20) | 停止信号,通常由用户在终端按 Ctrl+Z 组合键生成,用于停止前台进程。 | 停止进程。 |
SIGTTIN (21) 和 SIGTTOU (22) | 后台进程试图从控制终端读取/写入数据时产生。 | 停止进程。 |
4.2.3 信号的捕获与处理
4.2.3.1 signal 函数
signal 函数是设置信号处理函数的简便方法,适用于简单的信号处理。以下是 signal 函数的原型:
void (*signal(int signum, void (*handler)(int)))(int);
- signum:要捕获的信号编号。
- handler:指向信号处理函数的指针。
下面通过一个例子展示如何使用 signal 函数捕获 SIGINT 信号(通常由 Ctrl+C 产生)并自定义处理逻辑。
//signal.c
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
// 信号处理函数
void handle_sigint(int sig) {
printf("Caught signal %d (SIGINT). Exiting...\n", sig);
exit(0);
}
int main() {
// 设置信号处理函数
if (signal(SIGINT, handle_sigint) == SIG_ERR) {
perror("signal");
exit(EXIT_FAILURE);
}
// 无限循环,等待信号
while (1) {
printf("Running...\n");
sleep(1);
}
return 0;
}
编译并运行程序,并在终端中按 Ctrl+C,程序将捕获 SIGINT 信号并执行自定义处理逻辑,输出如下
4.2.3.2 sigaction函数
signal 函数适用于简单的信号处理,而 sigaction 提供了更高级的功能和更高的灵活性,适用于复杂的信号处理场景。以下是 sigaction 函数的原型:
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
- signum:要捕获的信号编号。
- act:新的信号处理行为。
- oldact:指向旧的信号处理行为(可以是 NULL)。
下面通过一个例子展示如何使用 sigaction 函数捕获 SIGUSR1 信号并自定义处理逻辑。
//sigaction.c
#include<stdio.h>
#include<stdlib.h>
#include<signal.h>
#include<unistd.h>
//信号处理函数
void handle_sigusr1(int sig)
{
printf("Caught signal %d (SIGUSR1)\n", sig);
exit(0);
}
int main()
{
struct sigaction sa;
//设置信号处理函数
sa.sa_handler = handle_sigusr1;
sa.sa_flags = 0; //额外选项
sigemptyset(&sa.sa_mask); //清空要屏蔽的信号集,表示信号在处理函数执行期间不屏蔽任何信号
//设置信号处理行为
if (sigaction(SIGUSR1, & sa, NULL) == -1)
{
perror("sigaction");
exit(EXIT_FAILURE);
}
//获取当前进程ID
pid_t pid = getpid();
printf("Process ID:%d\n", pid);
//等待信号
while (1)
{
printf("Running...\n");
sleep(1);
}
return 0;
}
编译并运行程序,打开另一个终端,输入kill -SIGUSR1 <pid>
,其中替换为已获取的进程ID
运行结果如下:
4.3 消息队列
消息队列(Message Queue)允许进程通过发送和接收消息进行通信,消息可以是任意格式的数据。消息队列提供了消息的队列缓冲区,允许进程按照先后顺序发送和接收消息。消息队列可以通过消息类型进行过滤,方便处理不同类型的消息。
在Linux系统中,使用msgget、msgsnd、msgrcv和msgctl等系统调用来管理消息队列。
4.3.1 msgget系统调用
msgget 用于创建一个新的消息队列或获取一个已存在的消息队列。
函数原型
int msgget(key_t key, int msgflg);
参数
- key:消息队列的键值,可以使用 ftok 函数生成唯一的键值。
- msgflg:标志位,用于指定消息队列的权限和创建条件,常见标志位包括 IPC_CREAT(如果不存在则创建)和权限(如 0644)。
返回值
成功时返回消息队列的标识符(msqid),失败时返回 -1 并设置 errno。
4.3.2 msgsnd 系统调用
msgsnd 用于向消息队列发送消息。
函数原型
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
参数
- msqid:消息队列的标识符。
- msgp:指向消息结构的指针。
- msgsz:消息内容的大小,以字节为单位。
- msgflg:标志位,用于指定发送选项,常见标志位包括 IPC_NOWAIT(如果队列已满则立即返回)。
返回值
成功时返回 0,失败时返回 -1 并设置 errno。
4.3.3 msgrcv 系统调用
msgrcv 用于从消息队列接收消息。
函数原型
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
参数
- msqid:消息队列的标识符。
- msgp:指向消息结构的指针,用于存储接收到的消息。
- msgsz:消息内容的大小,以字节为单位。
- msgtyp:消息类型,指定要接收的消息类型。如果为 0,接收队列中的第一个消息。
- msgflg:标志位,用于指定接收选项,常见标志位包括 IPC_NOWAIT(如果队列为空则立即返回)和 MSG_NOERROR(如果消息内容长度超过 msgsz,则截断消息)。
返回值
成功时返回接收到的消息大小,失败时返回 -1 并设置 errno。
4.3.4 msgctl 系统调用
msgctl 用于控制消息队列,包括获取信息、设置权限和删除消息队列。
函数原型
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
参数
- msqid:消息队列的标识符。
- cmd:控制命令,如 IPC_STAT(获取消息队列的信息)、IPC_SET(设置消息队列的信息)和 IPC_RMID(删除消息队列)。
- buf:指向 msqid_ds 结构体的指针,用于存储或设置消息队列的信息。
返回值
成功时返回 0,失败时返回 -1 并设置 errno。
4.3.5 程序示例及测试
下面通过完整的代码,展示如何使用消息队列进行进程间通信。
发送消息
//msg_sender.c
#include<stdio.h>
#include<stdlib.h>
#include<sys/ipc.h>
#include<sys/msg.h>
#include<string.h>
//消息队列的键值
#define MSG_KEY 0x1234
//定义消息结构
struct msgbuf
{
long mtype; //消息类型
char mtext[100]; //消息内容
};
int main()
{
//创建消息队列
int msqid = msgget(MSG_KEY, 0644 | IPC_CREAT);
if (msqid == -1)
{
perror("msgget");
exit(EXIT_FAILURE);
}
struct msgbuf msg;
msg.mtype = 1; //设置消息类型
//提示用户输入消息内容
printf("Enter message:");
fgets(msg.mtext, sizeof(msg.mtext), stdin); //读取消息内容
msg.mtext[strcspn(msg.mtext, "\n")] = '\0'; //移除换行符
//发送消息
if (msgsnd(msqid, &msg, sizeof(msg.mtext), 0) == -1)
{
perror("msgsnd");
exit(EXIT_FAILURE);
}
printf("Message successfully sent.\n");
return 0;
}
接受消息
//msg_receiver.c
#include<stdio.h>
#include<stdlib.h>
#include<sys/ipc.h>
#include<sys/msg.h>
#define MSG_KEY 0x1234
struct msgbuf
{
long mtype;
char mtext[100];
};
int main()
{
//获取消息队列
int msqid = msgget(MSG_KEY, 0644);
if (msqid == -1)
{
perror("msgget");
exit(EXIT_FAILURE);
}
struct msgbuf msg;
//接受消息
if (msgrcv(msqid, &msg, sizeof(msg.mtext), 0, 0) == -1)
{
perror("msgrcv");
exit(EXIT_FAILURE);
}
printf("Received message:%s\n", msg.mtext);
//删除消息队列
if (msgctl(msqid, IPC_RMID, NULL) == -1)
{
perror("msgctl");
exit(EXIT_FAILURE);
}
return 0;
}
编译和运行
1.编译程序
gcc -o msg_sender msg_sender.c gcc -o msg_receiver msg_receiver.c
2.运行发送消息程序
./msg_sender
输入一些文本并按Enter键。
3.运行接收消息程序
./msg_receiver
结果如下
4.4 共享内存
共享内存(Shared Memory)允许多个进程访问同一块物理内存区域,进程可以直接读写该区域的数据,是一种高效的通信方式,适用于需要频繁交换数据的进程,但需要进程同步来避免数据竞争和一致性问题。
在Linux中,使用shmget、shmat、shmdt和shmctl等系统调用来管理共享内存。
4.4.1 shmget 系统调用
shmget 用于创建一个新的共享内存段或者获取一个已存在的共享内存段。
函数原型*
int shmget(key_t key, size_t size, int shmflg);
参数
- key:共享内存段的键值。可以使用 ftok 函数生成一个唯一的键值。
- size:共享内存段的大小,以字节为单位。
- shmflg:标志位,用于指定共享内存段的权限和创建条件。常见标志位包括 IPC_CREAT(如果不存在则创建)和权限(如 0644)。
返回值
成功时返回共享内存段的标识符(shmid),失败时返回 -1 并设置 errno 。
4.4.2 shmat 系统调用
shmat 用于将共享内存段附加到调用进程的地址空间。
函数原型
void* shmat(int shmid, const void* shmaddr, int shmflg);
参数
- shmid:共享内存段的标识符,由 shmget 返回。
- shmaddr:指定共享内存段应附加到的地址,一般传递 NULL 让系统选择地址。
- shmflg:标志位,用于指定附加选项。常见标志位包括 SHM_RDONLY(只读附加)。
返回值
成功时返回指向共享内存段的指针,失败时返回 (void*) -1 并设置 errno 。
4.4.3 shmdt 系统调用
shmdt 用于将共享内存段从调用进程的地址空间分离。
函数原型
int shmdt(const void* shmaddr);
参数
- shmaddr:指向共享内存段的指针,由 shmat 返回。
返回值
成功时返回 0,失败时返回 -1 并设置 errno 。
4.4.4 shmctl 系统调用
shmctl 用于控制共享内存段,包括获取信息、设置权限和删除共享内存段。
函数原型
int shmctl(int shmid, int cmd, struct shmid_ds* buf);
参数
- shmid:共享内存段的标识符。
- cmd:控制命令,如 IPC_STAT(获取共享内存段的信息)、IPC_SET(设置共享内存段的信息)和 IPC_RMID(删除共享内存段)。
- buf:指向 shmid_ds 结构体的指针,用于存储或设置共享内存段的信息。
返回值
成功时返回 0,失败时返回 -1 并设置 errno。
4.4.5 程序示例及测试
下面通过一个完整示例,演示了如何使用上述系统调用创建、附加、分离和删除共享内存段。
写入共享内存
//shm_writter.c
#include<stdio.h>
#include<stdlib.h>
#include<sys/ipc.h>
#include<sys/shm.h>
#include<string.h>
//共享内存的键值,"0x1234"表示是一个任意选择的键值
#define SHM_KEY 0x1234
//共享内存段大小
#define SHM_SIZE 1024
int main()
{
//创建共享内存段
int shmid = shmget(SHM_KEY, SHM_SIZE, 0644 | IPC_CREAT); //0644 是权限标志,IPC_CREAT 标志表示如果共享内存段不存在则创建它。
if (shmid == -1)
{
perror("shmget");
exit(EXIT_FAILURE);
}
//将共享内存段附加到进程的地址空间
char* data = (char*)shmat(shmid, NULL, 0); //NULL 表示让系统选择附加地址,0 表示没有特殊标志。
if (data == (char*)-1)
{
perror("shmat");
exit(EXIT_FAILURE);
}
//写入数据到共享内存
printf("Writting to shared memory:"); //提示用户输入数据
fgets(data, SHM_SIZE, stdin); //从标准输入读取数据并写入共享内存段
//分离共享内存段
if (shmdt(data) == -1)
{
perror("shmdt");
exit(EXIT_FAILURE);
}
return 0;
}
读取共享内存
//shm_reader.c
#include<stdio.h>
#include<stdlib.h>
#include<sys/ipc.h>
#include<sys/shm.h>
#define SHM_KEY 0x1234
#define SHM_SIZE 1024
int main()
{
//获取内存共享段
int shmid = shmget(SHM_KEY, SHM_SIZE, 0644);
if (shmid == -1)
{
perror("shmget");
exit(EXIT_FAILURE);
}
//将共享内存段附加到进程的地址空间
char* data = (char*)shmat(shmid, NULL, 0);
if (data == (char*)-1)
{
perror("shmat");
exit(EXIT_FAILURE);
}
//读取共享内存中的数据
printf("Data read from shared memory:%s\n", data);
//分离共享内存段
if (shmdt(data) == -1)
{
perror("shmdt");
exit(EXIT_FAILURE);
}
//删除共享内存段
if (shmctl(shmid, IPC_RMID, NULL) == -1)
{
perror("shmctl");
exit(EXIT_FAILURE);
}
return 0;
}
运行测试结果如下:
4.5 信号量
信号量(Semaphore)是一种计数器,用于控制多个进程对共享资源的访问。可以用于实现进程同步和互斥,防止多个进程同时访问共享资源。常见操作包括P(wait)操作和V(signal)操作。
- 初始化:将信号量的值设置为1。
- P操作(等待):如果信号量的值为1,则将其减为0并进入临界区;如果信号量的值为0,则阻塞等待。
- V操作(释放):将信号量的值设置为1,并唤醒等待的进程。
下面展开进行介绍。
4.5.1 semget 系统调用
semget 用于创建一个新的信号量集或获取一个已存在的信号量集。
函数原型
int semget(key_t key, int nsems, int semflg);
参数
- key:信号量集的键值,可以使用 ftok 函数生成唯一的键值。
- nsems:信号量集中信号量的数量。
- semflg:标志位,用于指定信号量集的权限和创建条件,常见标志位包括 IPC_CREAT(如果不存在则创建)和权限(如 0644)。
返回值
成功时返回信号量集的标识符(semid),失败时返回 -1 并设置 errno。
4.5.2 semop 系统调用
semop 用于对信号量进行P操作(等待)或V操作(释放)。
函数原型
int semop(int semid, struct sembuf *sops, size_t nsops);
参数
- semid:信号量集的标识符。
- sops:指向 sembuf 结构体的指针,定义要进行的操作。
- nsops:要进行的操作数量。
sembuf结构体
struct sembuf 结构体通常定义在 <sys/sem.h> 头文件中。它的定义如下:
struct sembuf {
unsigned short sem_num; // 信号量的编号
short sem_op; // 操作:负值表示P操作,正值表示V操作
short sem_flg; // 操作标志
};
返回值
成功时返回 0,失败时返回 -1 并设置 errno。
4.5.3 semctl 系统调用
semctl 用于控制信号量集,包括初始化信号量、获取信号量值和删除信号量集等。
函数原型
int semctl(int semid, int semnum, int cmd, ...);
参数
- semid:信号量集的标识符。
- semnum:信号量的编号。
- cmd:控制命令,如 SETVAL(设置信号量的值)、GETVAL(获取信号量的值)和 IPC_RMID(删除信号量集)。
- 其他参数:根据 cmd 的不同而不同。
返回值
成功时返回命令执行的结果,失败时返回 -1 并设置 errno。
4.5.4 代码示例
下面通过一个完整示例,展示如何使用信号量进行进程间同步。
生产者进程
//sem_producer.c
#include<stdio.h>
#include<stdlib.h>
#include<sys/ipc.h>
#include<sys/sem.h>
#include<unistd.h>
#define SEM_KEY 0x1234
int main()
{
//创建信号量集
int semid = semget(SEM_KEY, 1, 0644 | IPC_CREAT);
if (semid == -1)
{
perror("semget");
exit(EXIT_FAILURE);
}
//初始化信号量值
if (semctl(semid, 0, SETVAL, 1) == -1)
{
perror("semctl");
exit(EXIT_FAILURE);
}
//生产者操作
struct sembuf sb = { 0,-1,0 }; //P操作
//模拟生产5个项目
for (int i=0; i < 5; i++)
{
if (semop(semid, &sb, 1) == -1)
{
perror("semop");
exit(EXIT_FAILURE);
}
//临界区
printf("Producing item %d\n", i + 1); //输出当前生产项目编号
sleep(1);
//V操作(释放)
sb.sem_op = 1;
if (semop(semid, &sb, 1) == -1)
{
perror("semop");
exit(EXIT_FAILURE);
}
sb.sem_op = -1; //重置为P操作
}
return 0;
}
消费者进程
//sem_consumer.c
#include<stdio.h>
#include<stdlib.h>
#include<sys/ipc.h>
#include<sys/sem.h>
#include<unistd.h>
#define SEM_KEY 0x1234
int main()
{
//获取信号量集
int semid = semget(SEM_KEY, 1, 0644);
if (semid == -1)
{
perror("semget");
exit(EXIT_FAILURE);
}
//消费者操作
struct sembuf sb = { 0,-1,0 }; //-1 P操作
//循环5次,模拟消耗5个项目
for (int i = 0;i < 5; i++)
{
//P操作(等待)
if (semop(semid, &sb, 1) == -1)
{
perror("semop");
exit(EXIT_FAILURE);
}
//临界区
printf("Consumer item %d\n", i + 1);
sleep(1);
//V操作(释放)
sb.sem_op = 1;
if (semop(sem, &sb, 1) == -1)
{
perror("semop");
exit(EXIT_FAILURE);
}
sb.sem_op = -1;
}
//删除信号量集,释放系统资源
if (semctl(semid, 0, IPC_RMID) == -1)
{
perror("semctl");
exit(EXIT_FAILURE);
}
return 0;
}
同步原理
通过信号量的 P 和 V 操作,生产者和消费者进程在访问共享资源时被同步:
- 当生产者进入临界区进行生产操作时,信号量值减 1,阻止消费者进入临界区,直到生产者完成并执行 V 操作。
- 当消费者进入临界区进行消费操作时,信号量值减 1,阻止生产者进入临界区,直到消费者完成并执行 V 操作。
这确保了在任何时刻只有一个进程(生产者或消费者)能够访问临界区,防止了竞争条件的发生。
4.5.5 信号量的分类
信号量主要有两种类型:二值信号量和计数信号量。
4.5.5.1 二值信号量
二值信号量只有0和1两个值,类似于互斥锁(Mutex),用于实现对共享资源的互斥访问。二值信号量的典型应用场景是确保某一时刻只有一个进程访问某个临界区(Critical Section)。上述例子使用的信号量均为二值信号量。
4.5.5.2 计数信号量
计数信号量可以有多个值,用于控制对有限资源的访问。信号量的值表示可以同时访问资源的进程数目。计数信号量常用于控制多个进程对一定数量资源的访问,例如限制同时访问某个数据库连接池的进程数。
示例
假设我们有5个资源:
// 初始化信号量为5
semctl(semid, 0, SETVAL, 5);
// P操作(等待)
struct sembuf sb = {0, -1, 0};
semop(semid, &sb, 1);
// 临界区代码
...
// V操作(释放)
sb.sem_op = 1;
semop(semid, &sb, 1);
4.5.5.3 二值与计数信号量的比较
特性 | 二值信号量 | 计数信号量 |
---|---|---|
信号量值范围 | 0 或 1 | 任意非负整数 |
典型应用场景 | 实现互斥访问 | 控制对有限资源的访问 |
等效机制 | 互斥锁 | 资源计数器 |
使用复杂度 | 较低 | 较高 |
初始值 | 1 | 资源的数量 |
P操作效果 | 将值减为0并进入临界区 | 将值减1并进入临界区 |
V操作效果 | 将值设置为1并释放临界区 | 将值加1并释放资源 |
4.6 文件
进程可以通过读写文件(File)来进行通信,其中一种进程把数据写入文件,另一种进程从文件中读取数据。文件通信简单易用,但不适合频繁的通信和大量数据传输。
4.6.1 文件通信的基本原理
文件通信的基本原理是使用文件系统中的普通文件作为中介,通过文件的读写操作实现进程间的数据交换。一个进程可以将数据写入文件,另一个进程则可以从该文件中读取数据。
4.6.2 文件通信的优缺点
优点
- 简单易用:文件通信机制简单,易于理解和实现。
- 持久化存储:文件存储在文件系统中,可以实现数据的持久化保存,即使系统重启,数据依然存在。
- 兼容性好:文件通信可以在不同的编程语言和操作系统之间使用,具有较好的兼容性。
缺点
- 效率低:文件通信的效率较低,特别是当文件较大时,读写操作会比较耗时。
- 同步问题:需要显式处理进程间的同步问题,避免读写冲突。
- 空间开销:文件占用磁盘空间,对于大量小数据的传输,空间开销较大。
4.6.3 文件通信的基本操作
文件通信的基本操作包括打开文件、写入数据、读取数据和关闭文件。下面直接用一个基本示例,展示如何使用文件进行进程间通信。
发送数据进程
//file_sender.c
#include<stdio.h>
#include<stdlib.h>
int main()
{
//打开文件
FILE* file = fopen("communication.txt", "w");
if (file == NULL)
{
perror("fopen");
exit(EXIT_FAILURE);
}
//写入数据
const char* message = "Hello from sender!";
if (fprintf(file, "%s", message) < 0)
{
perror("fprintf");
fclose(file);
exit(EXIT_FAILURE);
}
//关闭文件,释放资源
fclose(file);
printf("Message sent successfully!\n");
return 0;
}
接收数据进程
//file_receiver.c
#include<stdio.h>
#include<stdlib.h>
int main()
{
FILE* file = fopen("communication.txt", "r");
if (file == NULL)
{
perror("fopen");
exit(EXIT_FAILURE);
}
//读取数据
char buffer[256];
if (fgets(buffer,sizeof(buffer), file) == NULL)
{
perror("fgets");
fclose(file);
exit(EXIT_FAILURE);
}
//打印接收到的数据
printf("Received message:%s\n", buffer);
fclose(file);
return 0;
}
经过编译运行,得到如下输出:
4.6.4 同步问题处理
由于文件通信存在同步问题,需要显式处理进程间的同步,以避免读写冲突。常见的同步机制包括:
- 文件锁:使用 fcntl 或 flock 系统调用对文件进行锁定。
- 临时文件:写入数据时使用临时文件,写完后重命名为目标文件。
- 信号量:使用信号量实现进程间的同步(前面已经进行介绍)。
4.6.4.1 使用文件锁进行同步
在写入数据和读取数据时,可以使用文件锁来确保进程间的同步,以下示例是一个文件发送程序,与上述版本相比,它使用了文件锁来确保在写入文件时的独占性。
发送数据进程
//file_sender_lock.c
#include<stdio.h>
#include<stdlib.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
int main()
{
//打开文件
int fd = open("communication.txt", O_WRONLY | O_CREAT, 0644);
if (fd == -1)
{
perror("open");
exit(EXIT_FAILURE);
}
//加锁
struct flock lock;
lock.l_type = F_WRLCK; //写锁
lock.l_whence = SEEK_SET;
lock.l_start = 0;
lock.l_len = 0;
//设置文件锁
if (fcntl(fd, F_SETLKW, &lock) == -1)
{
perror("fcntl");
close(fd);
exit(EXIT_FAILURE);
}
//写入数据
const char* message = "Hello from sender!";
if (write(fd, message, strlen(message)) == -1)
{
perror("write");
//释放锁
lock.l_type = F_UNLCK;
fcntl(fd, F_SETLK, &lock);
close(fd);
exit(EXIT_FAILURE);
}
//释放锁
lock.l_type = F_UNLCK;
if (fcntl(fd, F_SETLK, &lock) == -1)
{
perror("fcntl");
exit(EXIT_FAILURE);
}
close(fd);
printf("Message sent successfully!\n");
return 0;
}
接收数据进程
//file_recevier_lock.c
#include<stdio.h>
#include<stdlib.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
int main()
{
//打开文件
int fd = open("communication.txt",O_RDONLY);
if (fd == -1)
{
perror("open");
exit(EXIT_FAILURE);
}
//加锁
struct flock lock;
lock.l_type = F_RDLCK; //读锁
lock.l_whence = SEEK_SET;
lock.l_start = 0;
lock.l_len = 0; //锁定整个文件
//设置文件锁
if (fcntl(fd, F_SETLKW, &lock) == -1)
{
perror("fcntl");
close(fd);
exit(EXIT_FAILURE);
}
//读取数据
char buffer[256];
ssize_t bytes_read = read(fd, buffer, sizeof(buffer) - 1);
if (bytes_read == -1)
{
perror("read");
//释放锁
lock.l_type = F_UNLCK;
fcntl(fd, F_SETLK, &lock);
close(fd);
exit(EXIT_FAILURE);
}
//添加字符串结束符
buffer[bytes_read] = '\0';
printf("Received message:%s\n", buffer);
//释放锁,以允许其他进程访问该文件
lock.l_type = F_UNLCK;
if (fcntl(fd, F_SETLK, &lock) == -1)
{
perror("fcntl");
close(fd);
exit(EXIT_FAILURE);
}
close(fd);
return 0;
}
4.6.4.2 使用临时文件进行同步
使用临时文件进行同步可以通过在写入临时文件时控制其存在与否来实现。这种方法依赖于文件系统的原子操作,确保在文件存在时,其他进程可以检测到并进行相应的处理。下面通过一个示例展示如何通过临时文件进行同步。
写入数据进程
//writter.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
// 创建并打开临时文件
FILE *file = fopen("tempfile.txt", "w");
if (file == NULL) {
perror("fopen");
exit(EXIT_FAILURE);
}
// 写入数据
fprintf(file, "Hello from process 1!\n");
// 关闭文件
fclose(file);
printf("Data written to tempfile.txt\n");
return 0;
}
读取数据进程
//reader.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
int main() {
// 等待临时文件的创建
while (access("tempfile.txt", F_OK) == -1) {
// 文件不存在,继续等待
usleep(100000); // 休眠100毫秒
}
// 打开临时文件
FILE *file = fopen("tempfile.txt", "r");
if (file == NULL) {
perror("fopen");
exit(EXIT_FAILURE);
}
// 读取数据
char buffer[256];
if (fgets(buffer, sizeof(buffer), file) != NULL) {
printf("Received message: %s", buffer);
}
// 关闭文件
fclose(file);
// 删除临时文件
if (remove("tempfile.txt") == -1) {
perror("remove");
exit(EXIT_FAILURE);
}
printf("tempfile.txt deleted\n");
return 0;
}
4.7 套接字(Socket)
套接字允许不同计算机上的进程进行通信,是网络通信的基础。套接字通信可以在同一台计算机上的不同进程间进行,也可以在网络上的不同主机间进行。关于套接字通信,我之前专门写过一篇文章:Socket通信,此处不再做介绍,欢迎读者进行阅读。
以上便是Linux内核之进程管理的全部内容,由于笔者能力有限,对部分内容的理解可能会有些许不严谨的地方,欢迎广大读者进行指正!若对以上内容有什么疑问,欢迎大家留言讨论!