目录
一、五大 IO 模型
1.1 完整的 IO 过程
1.2 阻塞 IO
1.3 非阻塞 IO
1.4 信号驱动式 IO
1.5 多路转接
1.6 异步 IO
二、有限状态机编程
2.1 基本思想
2.2 数据中继模型
2.3 数据中继实现
2.4 中继引擎实现
三、IO多路转接
3.1 select
3.2 poll
3.3 epoll
3.3.1 epoll_create
3.3.2 epoll_ctl
3.3.3 epoll_wait
3.3.4 代码示例
四、其他读写函数
4.1 readv
4.2 writev
五、存储映射 IO
5.1 简介
5.2 共享内存用作进程间通信
六、文件锁
6.1 lockf
6.2 flock
6.3 fcntl
一、五大 IO 模型
1.1 完整的 IO 过程
即进程发起 IO 调用请求,然后由内核执行 IO。内核执行 IO 又包括两个阶段(以从设备读取数据为例):
- 数据准备阶段:内核等待 I/O 设备获取数据,并将数据填至内核缓冲区
- 数据拷贝阶段:将数据从内核缓冲区拷贝到用户进程缓冲区
上述整个过程如图所示
根据数据准备阶段及数据拷贝阶段的行为不同,可以分为如下五类IO模型:
- 阻塞 IO
- 非阻塞 IO
- 信号驱动式 IO
- 多路转接
- 异步 IO
1.2 阻塞 IO
在内核准备数据过程中,进程一直阻塞等待
1.3 非阻塞 IO
如果内核还未将数据准备完毕,系统调用仍然会直接返回 EWOULDBLOCK 错误码。非阻塞 IO 往往需要反复查看数据准备完毕没有,这个过程称为轮询
1.4 信号驱动式 IO
当进程发起一个 IO 操作,会向内核注册一个信号处理函数,然后进程不阻塞直接返回;待内核将数据准备完毕时,主动使用 SIGIO 信号通知进程,进程在信号处理函数中发起 IO 调用
1.5 多路转接
和阻塞 IO 类似,在内核准备数据过程中,进程也会阻塞等待。不过是同时等待多个文件描述符的数据准备状态
1.6 异步 IO
内核在数据拷贝完成时再通知应用程序。在此期间应用程序继续执行,不阻塞
如果用拟人化的比喻,那么:
- 阻塞 IO:啥别的事也不做,一直盯着鱼竿,直到鱼上钩就钓
- 非阻塞 IO:边看手机边钓鱼,需要时不时看看鱼上钩没,上钩就钓
- 信号驱动式 IO:在鱼竿上放个铃铛,然后干别的事,直到听到铃铛响,说明上钩,钓
- 多路转接:一次带来几百个鱼竿钓,盯着这一堆鱼竿,哪个上钩就钓哪个
- 异步IO:让别人帮自己钓鱼,自己干别的事儿就行,别人钓到鱼了直接给你
二、有限状态机编程
是一种编程思想,非常适合用来解决需要非结构化程序控制流程才能解决的问题
- 结构化程序控制流程:程序应该有清晰、易于理解的控制结构,通常由顺序执行、条件分支和循环控制结构组成。这些结构化元素使得程序的流程易于跟踪,逻辑清晰
- 非结构化程序控制流程:非结构化的程序流程则没有遵循这种严格的、有序的控制流。在非结构化的程序中,流程控制可能大量依赖于跳转语句(如 goto),这使得程序的执行路径不那么明显,难以追踪
2.1 基本思想
有限状态机(FSM)的思想就像是玩“红绿灯”游戏一样。在这个游戏里,你可以是“停止”状态,也可以是“行走”状态。如果现在是红灯,你就得停下来;如果是绿灯,你就可以走。当红灯变绿灯时,你从“停止”状态变到“行走”状态,而当绿灯变红灯时,你又得从“行走”状态变回“停止”状态。就这么简单!
当我们用这个思想来解决问题时,通常会遵循这样的流程:
- 确定状态:首先,你得知道都有哪些状态。就像红绿灯游戏,只有“停止”和“行走”两种状态
- 确定事件:然后,你要弄清楚有哪些事件。在红绿灯游戏里,有等变红和等变绿两个事件
- 制定规则:接下来,你要制定规则,规则决定了事件如何让状态变化。比如,当红灯亮起时,你就得停下;当绿灯亮起时,你就可以走
- 执行状态转换:最后,根据你的规则,当事件发生时,你就改变状态。就像你在游戏里看到绿灯就开始走
使用有限状态机的思想来解决问题,就是按这个方法来一步步设计你的系统,这样你就可以清楚地知道在任何时候系统应该做什么,以及它将如何响应不同的事件。这种方法让问题变得简单,因为你一次只处理一个状态和几个事件,就像一个接一个地走红绿灯一样
接下来,我们使用有限状态机的思想,结合非阻塞 IO 完成一个数据中继的编程实例
2.2 数据中继模型
数据中继可以理解为一个数据中转站。如下图所示,两两用户之间进行数据交互需要通过服务器,由服务器来做这个数据中转。如何中转是我们需要研究的问题
将这个模型进行简化抽象,我们发现两两用户之间的数据交互其实就是双方的一个数据交换。逻辑上需要做的就是不断执行如下两件事:
- 从 tty1 中读取数据,写入 tty2
- 从 tty2 中读取数据,写入 tty1
显然,上述的读取写入涉及到了 IO 操作。显然,应该用非阻塞 IO。因为如果用阻塞的 IO,那么当 tty1 没有数据但是 tty2 有数据时,进程可能一直阻塞在“读 tty1”的阶段,就很呆(tty 代表设备)
2.3 数据中继实现
需求:有左右两个设备,第一个任务为读左设备,写右设备;第二个任务读右设备,写左设备
这两个任务本质上都是读取设备并写入另一个设备。用有限状态机的思想分析任务的状态、事件和规则,可以绘制出如下状态转移图
上图中,红色框代表任务可能的状态,连接各状态之间的箭头代表了可能的事件及状态转移规则。基于这个状态转移图,我们来实现我们的代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <unistd.h>
#define TTY1 "/dev/pts/1"
#define TTY2 "/dev/pts/2"
#define BUFSIZE 1024
enum
{
STATE_R = 1, // 读取态
STATE_W, // 写入态
STATE_Ex, // 异常error态
STATE_T // 终止态
};
struct fsm_st
{
int state; // 状态
int sfd; // 源文件
int dfd; // 目标文件
char buf[BUFSIZE]; // 缓冲区
int len; // 记录缓冲区中读取到的字节数
int pos; // 记录尚未被写入内容的起始位置
char * errstr; // 记录出错信息
};
static void fsm_driver(struct fsm_st* fsm) { // 驱动函数:状态执行一次转换(注意仅转移一次)
int ret;
switch (fsm->state) {
case STATE_R: // 从读取态往别的状态转移
fsm->len = read(fsm->sfd, fsm->buf, BUFSIZE);
if (fsm->len == 0)
fsm->state = STATE_T; // 读取到文件尾,状态转移至终止态
else if (fsm->len < 0)
{
if (errno == EAGAIN)
fsm->state = STATE_R; // 假错,转移至读取态
else {
fsm->state = STATE_Ex; // 真错,转移至异常态
fsm->errstr = "error in read()";
}
}
else {
fsm->state = STATE_W; // 读取成功,转移至写态
fsm->pos = 0; // 此时buf中所有内容都还没被写入,pos置0
}
break;
case STATE_W:
ret = write(fsm->dfd, fsm->buf + fsm->pos, fsm->len);
if (ret < 0)
{
if (errno = EAGAIN)
fsm->state = STATE_W; // 假错,转移至写态
else {
fsm->state = STATE_Ex; // 真错,转移至异常态
fsm->errstr = "error in write()";
}
}
else
{
fsm->pos += ret; // 写入了ret字节
// 尚未被写入内容的起始位置后移ret
fsm->len -= ret;
if (fsm->len == 0)
fsm->state = STATE_R; // 写入完毕,转移至读取态
else {
fsm->state = STATE_W; // 写入未完成,转移至写入态
}
}
break;
case STATE_Ex:
perror(fsm->errstr);
fsm->state = STATE_T;
break;
case STATE_T:
/* do sth */
break;
default:
abort();
break;
}
}
static void relay(int fd1, int fd2) {
// 将文件描述符fd1与fd2都设置为“以非阻塞形式打开”
int fd1_save = fcntl(fd1, F_GETFL);
fcntl(fd1, F_SETFL, fd1_save|O_NONBLOCK);
int fd2_save = fcntl(fd2, F_GETFL);
fcntl(fd2, F_SETFL, fd2_save|O_NONBLOCK);
struct fsm_st fsm12, fsm21; // 两个任务
// 需要两个状态转移过程
// fsm12维护从fd1读写入fd2的状态转移过程
// fsm21维护从fd2读写入fd1的状态转移过程
fsm12.state = STATE_R;
fsm12.sfd = fd1;
fsm12.dfd = fd2;
fsm21.state = STATE_R;
fsm21.sfd = fd2;
fsm21.dfd = fd1; // 初始状态
while (fsm12.state != STATE_T || fsm21.state != STATE_T) {
fsm_driver(&fsm12); // 不断驱动状态转换
fsm_driver(&fsm21); //
}
fcntl(fd1, F_SETFL, fd1_save); // 恢复文件描述符的默认打开方式
fcntl(fd2, F_SETFL, fd2_save);
}
int main() {
int fd1, fd2;
fd1 = open(TTY1, O_RDWR);
if (fd1 < 0)
{
perror("open()");
exit(1);
}
write(fd1, "TTY1\n", 5);
fd2 = open(TTY2, O_RDWR | O_NONBLOCK);
if (fd2 < 0)
{
perror("open()");
exit(1);
}
write(fd2, "TTY2\n", 5);
relay(fd1, fd2); // 数据交换的双方为fd1和fd2
close(fd2);
close(fd1);
exit(0);
}
为了测试我们的代码,我们打开三个终端,其中一个终端运行我们的 ./a.out 进程,另外两个终端作为我们数据交换的两个设备。可通过命令 tty 显示当前终端用的哪个虚拟控制台
可以看到,数据交换成功!
上述代码存在忙等现象,会使得CPU利用率占满,原因在于如下代码:
while(fsm12.state != STATE_T || fsm21.state != STATE_T) { fsm_driver(&fsm12); fsm_driver(&fsm21); }
如果设备没有准备好数据,则进入 fsm_driver 后,执行 read 调用时,内核立即会返回(非阻塞),是一个假错,执行:
if(errno == EAGAIN) { // 通常在执行非阻塞io时引发EAGAIN,这意味着“现在没有可用的数据,以后再试一次” 。 fsm->state = STATE_R; }
状态不变,跳出 case 语句和驱动函数后,继续循环,所以导致 cpu 利用率高
2.4 中继引擎实现
在数据中继实现中,我们实现了两个设备进行数据交换的例子。现在我们想实现管理 100 对设备两两交换的中继引擎
我们将代码封装成库,并在 main.c 中模拟用户使用库函数的过程。详细实现如下(呜呜呜😆~~~这下是在没有任何教学的情况下亲自手把手写的,泪目!但是给自己点赞!!!😀)
共编写三个代码文件,relayer.h、relayer.c 和 main.c,详细内容及含义见下
relayer.h,主要描述了提供给用户的接口,用户能够看到
#ifndef RELAYER_H__
#define RELAYER_H__
#define JOBMAX 100
enum
{
STATE_RUNNING = 1, // 任务运行中
STATE_CANCELED, // 任务被取消
STATE_OVER // 任务完成
}; // 描述单个任务的状态
struct rel_job_user_st // 暴露给用户的描述job的结构体
{
int state; // 该任务的状态
int fd1; // 该任务交互的双方
int fd2;
time_t start; // 任务起始时间(s)
time_t end; // 任务终止时间(s)
};
// 创建描述任务的结构体,并存放在任务数组的某个位置
int rel_addjob(int fd1, int fd2);
/* return >= 0 成功,返回描述任务的结构体存放在数组的哪个下标(作为任务ID)
* == -EINVAL 失败,参数非法
* == -NOSPC 失败,任务数据满
* == -ENOMEM 失败,内存不足
*/
// 取消一个任务
int rel_canceljob(int id);
/* return == 0 成功,指定任务成功取消
* == -EINVAL 失败,参数非法
* == -EBUSY 失败,任务已处于非运行态,无需取消
*/
// 回收一个非运行态的任务
int rel_waitjob(int id, struct rel_job_user_st *);
/* return == 0 成功,指定任务已终止,并顺利收尸
* == -EINVAL 失败,参数非法
*/
#endif
relayer.c,主要描述了接口的具体实现,对用户隐藏
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <unistd.h>
#include <pthread.h>
#include <string.h>
#include "relayer.h"
#define BUFSIZE 1024
enum
{
STATE_R = 1, // 读取态
STATE_W, // 写入态
STATE_Ex, // 异常error态
STATE_T // 终止态
};
struct fsm_st
{
int state; // 状态
int sfd; // 源文件
int dfd; // 目标文件
char buf[BUFSIZE]; // 缓冲区
int len; // 记录缓冲区中的字节数
int pos; // 记录尚未被写入内容的起始位置
char * errstr; // 记录出错信息
};
struct rel_job_st // 真正描述job的结构体
{
int state;
int fd1, fd1_save;
int fd2, fd2_save;
struct fsm_st fsm12, fsm21;
time_t start;
time_t end;
pthread_mutex_t mutex; // 保证互斥访问结构体的成员
};
static struct rel_job_st* job[JOBMAX];
static pthread_mutex_t mutex_job = PTHREAD_MUTEX_INITIALIZER; // 保证互斥访问job数组
static pthread_once_t init_once = PTHREAD_ONCE_INIT;
static pthread_t tid; // 不断驱动状态转换的线程
static void fsm_driver(struct fsm_st* fsm) { // 状态执行一次转换(注意仅转移一次)
int ret;
switch (fsm->state) {
case STATE_R: // 从读取态往别的状态转移
fsm->len = read(fsm->sfd, fsm->buf, BUFSIZE);
if (fsm->len == 0)
fsm->state = STATE_T; // 读取到文件尾,状态转移至终止态
else if (fsm->len < 0)
{
if (errno == EAGAIN)
fsm->state = STATE_R; // 假错,转移至读取态
else {
fsm->state = STATE_Ex; // 真错,转移至异常态
fsm->errstr = "error in read()";
}
}
else {
fsm->state = STATE_W; // 读取成功,转移至写态
fsm->pos = 0; // 此时buf中所有内容都还没被写入,pos置0
}
break;
case STATE_W:
ret = write(fsm->dfd, fsm->buf + fsm->pos, fsm->len);
if (ret < 0)
{
if (errno = EAGAIN)
fsm->state = STATE_W; // 假错,转移至写态
else {
fsm->state = STATE_Ex; // 真错,转移至异常态
fsm->errstr = "error in write()";
}
}
else
{
fsm->pos += ret; // 写入了ret字节
// 尚未被写入内容的起始位置后移ret
fsm->len -= ret;
if (fsm->len == 0)
fsm->state = STATE_R; // 写入完毕,转移至读取态
else {
fsm->state = STATE_W; // 写入未完成,转移至写入态
}
}
break;
case STATE_Ex:
perror(fsm->errstr);
fsm->state = STATE_T;
break;
case STATE_T:
/* do sth */
break;
default:
abort();
break;
}
}
static void* thr_relayer(void*p) { // 不断推动状态的线程
// 该线程会不断访问job数组及访问job数组中的结构体中的成员
// 我们需要引入互斥量
while (1) {
pthread_mutex_lock(&mutex_job); // 访问job前先上锁
for (int i = 0; i < JOBMAX; ++i)
{
if (job[i] != NULL) {
pthread_mutex_lock(&job[i]->mutex); // 访问结构体的成员前先上锁
if (job[i]->state == STATE_RUNNING)
{
fsm_driver(&job[i]->fsm12);
fsm_driver(&job[i]->fsm21);
if (job[i]->fsm12.state == STATE_T && job[i]->fsm21.state == STATE_T)
job[i]->state = STATE_OVER;
}
pthread_mutex_unlock(&job[i]->mutex);
}
}
pthread_mutex_unlock(&mutex_job);
}
}
static void module_unload(void) {
pthread_cancel(tid); // 终止不断推动状态机的线程
pthread_join(tid, NULL); // 收尸
for (int i = 0; i < JOBMAX; ++i) { // 这下可以安安心心,无需上锁访问数组中的结构体的成员
if (job[i] != NULL) {
fcntl(job[i]->fd1, F_SETFL, job[i]->fd1_save);
fcntl(job[i]->fd2, F_SETFL, job[i]->fd2_save);
free(job[i]);
}
}
}
static void module_load(void) // 创建出那个不断推动状态机的线程
{
int err = pthread_create(&tid, NULL, thr_relayer, NULL);
if (err)
{
fprintf(stderr, "pthread_create():%s\n", strerror(err));
exit(1);
}
atexit(module_unload);
}
static int get_free_pos_unlocked()
{
for (int i = 0; i < JOBMAX; ++i)
{
if (job[i] == NULL)
return i;
}
return -1;
}
int rel_addjob(int fd1, int fd2){
struct rel_job_st *me;
pthread_once(&init_once,module_load); // 创建一个中继驱动,不断推动各任务状态机
me = malloc(sizeof(*me));
if (me == NULL)
return -ENOMEM;
// 初始化任务
pthread_mutex_init(&me->mutex, NULL);
me->fd1 = fd1;
me->fd2 = fd2;
me->state = STATE_RUNNING;
me->start = time(NULL);
me->end = time(NULL);
me->fd1_save = fcntl(me->fd1, F_GETFL);
fcntl(me->fd1, F_SETFL, me->fd1_save|O_NONBLOCK);
me->fd2_save = fcntl(me->fd2, F_GETFL);
fcntl(me->fd2, F_SETFL, me->fd2_save|O_NONBLOCK);
me->fsm12.sfd = me->fd1;
me->fsm12.dfd = me->fd2;
me->fsm12.state = STATE_R;
me->fsm21.sfd = me->fd2;
me->fsm21.dfd = me->fd1;
me->fsm21.state = STATE_R;
pthread_mutex_lock(&mutex_job);
// 访问job数组前需要加锁
int pos = get_free_pos_unlocked();
if (pos < 0)
{
pthread_mutex_unlock(&mutex_job); // 别忘了解锁
fcntl(me->fd1, F_SETFL, me->fd1_save); // 恢复文件描述符行为
fcntl(me->fd2, F_SETFL, me->fd2_save);
free(me);
return -ENOSPC;
}
job[pos] = me;
pthread_mutex_unlock(&mutex_job);
return pos;
}
int rel_canceljob(int id) {
pthread_mutex_lock(&mutex_job); // 访问job数组前需要加锁
if (job[id] == NULL) {
pthread_mutex_unlock(&mutex_job);
return -EINVAL;
}
pthread_mutex_lock(&job[id]->mutex); // 访问job数组中的结构体中的成员需要加锁
if (job[id]->state == STATE_OVER || job[id]->state == STATE_CANCELED) { // 非运行态的任务无需取消
pthread_mutex_unlock(&job[id]->mutex);
pthread_mutex_unlock(&mutex_job);
return -EBUSY;
}
job[id]->state = STATE_CANCELED; // 置为取消
job[id]->end = time(NULL); // 记录被取消的时间
pthread_mutex_unlock(&job[id]->mutex);
pthread_mutex_unlock(&mutex_job);
return 0;
}
int rel_waitjob(int id, struct rel_job_user_st * jobinfo) {
pthread_mutex_lock(&mutex_job); // 访问job数组,加锁
pthread_mutex_lock(&job[id]->mutex); // 访问数组中的结构体中的成员,加锁
if (job[id] == NULL || job[id]->state == STATE_RUNNING) {
pthread_mutex_unlock(&job[id]->mutex);
pthread_mutex_unlock(&mutex_job);
return -EINVAL;
}
jobinfo->state = job[id]->state; // 将任务的信息存入暴露给用户的描述任务的结构体,返回给用户
jobinfo->fd1 = job[id]->fd1;
jobinfo->fd2 = job[id]->fd2;
jobinfo->start = job[id]->start;
jobinfo->end= job[id]->end;
fcntl(job[id]->fd1, F_SETFL, job[id]->fd1_save); // 恢复fd默认行为
fcntl(job[id]->fd2, F_SETFL, job[id]->fd2_save);
pthread_mutex_unlock(&job[id]->mutex);
free(job[id]);
pthread_mutex_unlock(&mutex_job);
return 0;
}
main.c,模拟用户使用接口的过程
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <unistd.h>
#include <string.h>
#include "relayer.h"
#define TTY1 "/dev/tty2"
#define TTY2 "/dev/tty3"
#define TTY3 "/dev/tty4"
#define TTY4 "/dev/tty5"
#define BUFSIZE 1024
int main() {
int fd1, fd2;
fd1 = open(TTY1, O_RDWR);
if (fd1 < 0)
{
perror("open()");
exit(1);
}
write(fd1, "TTY1\n", 5);
fd2 = open(TTY2, O_RDWR | O_NONBLOCK);
if (fd2 < 0)
{
perror("open()");
exit(1);
}
write(fd2, "TTY2\n", 5);
int job1 = rel_addjob(fd1, fd2); // 数据交换的双方为fd1和fd2
if (job1 < 0) {
fprintf(stderr, "rel_addjob():%s\n", strerror(-job1));
}
int fd3, fd4;
fd3 = open(TTY3, O_RDWR);
if (fd3 < 0)
{
perror("open()");
exit(1);
}
write(fd3, "TTY3\n", 5);
fd4 = open(TTY4, O_RDWR);
if (fd4 < 0)
{
perror("open()");
exit(1);
}
write(fd4, "TTY4\n", 5);
int job2 = rel_addjob(fd3, fd4); // 数据交换的双方是fd3和fd4
if (job2 < 0) {
fprintf(stderr, "rel_addjob():%s\n", strerror(-job2));
}
sleep(60); // 休眠60s
int err;
err = rel_canceljob(job2); // 取消任务2
if (err < 0)
fprintf(stderr, "rel_canceljob():%s\n", strerror(err));
struct rel_job_user_st * info;
info = malloc(sizeof(*info));
err = rel_waitjob(job2, info); // 收尸任务2
if (err < 0)
fprintf(stderr, "rel_waitjob():%s\n", strerror(err));
// 打印一些job2的终止信息
fprintf(stdout, "job2's end state is %d, keeps running %lds\n", info->state, info->end-info->start);
sleep(30); // 休眠20s
// 取消任务1及收尸任务1,错误校验及打印收尸信息略
rel_canceljob(job1);
rel_waitjob(job1, info);
close(fd4);
close(fd3);
close(fd2);
close(fd1);
exit(0);
}
接下来我们开始测试
由上可以看出,数据中继引擎功能基本实现
三、IO多路转接
IO 多路转接模型核心思路:系统给我们提供一类函数(select、poll、epoll 函数),它们可以同时监控多个文件描述符的数据准备状态,任何一个返回内核数据准备完毕,应用进程再发起 recvfrom 系统调用
3.1 select
古老的函数,可移植性好,但是有很多缺陷。man 2 select
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
功能:监视文件描述符的可读写状态及异常状态
- nfds — 传入该形参的实参值应为:待监视的一堆文件描述符中,值最大的那个文件描述符(别忘了文件描述符本质上为整型值)的值再加一
- readfds — 指向文件描述符集。如果该文件描述符集中有文件描述符可读了,select 即返回
- writefds — 指向文件描述符集。如果该文件描述符集中有文件描述符可写了,select 即返回
- exceptfds — 指向文件描述符集。如果该文件描述符集中有文件描述符异常了,select 即返回
- 若 readfds 中文件描述符无一可读且 writefds 中文件描述符无一可写且 exceptfds 中文件描述符无一异常,则一直阻塞
- timeout — 微秒级的超时设置。该函数最多阻塞的时间为 timeout 所指定的时间
- 成功则返回 readfds 中可读的文件描述符个数 + writefds 中可写的文件描述符个数 + exceptfds 中异常的文件描述符个数,并仅在 readfds 中保留可读的文件描述符、在 writefds 中保留可写的文件描述符、在 exceptfds 保留异常的文件描述符;失败则返回 -1,并设置 errno,且此时传入函数的那三个文件描述符集中的内容变得不可预知
上面涉及到了“文件描述符集”,用来表示一堆文件描述符所构成的集合,在这里表示这些文件描述符是“被监视的”。诶?之前是不是遇到过类似的东东?对!信号集也是类似的概念,只不过信号集是用来表示一堆信号所构成的集合。操作文件描述符集的相关调用如下
#include <sys/select.h> void FD_CLR(int fd, fd_set *set); // 从文件描述符集set中删除文件描述符fd int FD_ISSET(int fd, fd_set *set); // 判断文件描述符fd是否在文件描述符集set中 void FD_SET(int fd, fd_set *set); // 将文件描述符fd添加到文件描述符集set中 void FD_ZERO(fd_set *set); // 清空文件描述符集set
- fd — 代表某个文件描述符
- set — 指向某个 fd_set 类型的文件描述符集
代码示例:重构 2.3 中的代码
需求:避免忙等现象的发生
之前的代码会出现忙等现象
究其原因是因为不断运行的 while 循环占用了 CPU:
while(fsm12.state != STATE_T || fsm21.state != STATE_T) { fsm_driver(&fsm12); fsm_driver(&fsm21); }
我们现在希望进行如下修改:
while(fsm12.state != STATE_T || fsm21.state != STATE_T) { // 布置监视任务 // 监视 select(); // 查看监视结果 if () fsm_driver(&fsm12); if () fsm_driver(&fsm21); }
满足一定条件才进行状态推动,否则阻塞在 select,这样一来,while 循环就不会一直死等运行占用 CPU 了
代码实现如下,详见注释
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <unistd.h>
#include <sys/select.h>
#define TTY1 "/dev/tty2"
#define TTY2 "/dev/tty3"
#define BUFSIZE 1024
enum
{
STATE_R = 1, // 读取态
STATE_W, // 写入态
STATE_Ex, // 异常error态
STATE_T // 终止态
};
struct fsm_st
{
int state; // 状态
int sfd; // 源文件
int dfd; // 目标文件
char buf[BUFSIZE]; // 缓冲区
int len; // 记录缓冲区中读取到的字节数
int pos; // 记录尚未被写入内容的起始位置
char * errstr; // 记录出错信息
};
static void fsm_driver(struct fsm_st* fsm) { // 驱动函数:状态执行一次转换(注意仅转移一次)
int ret;
switch (fsm->state) {
case STATE_R: // 从读取态往别的状态转移
fsm->len = read(fsm->sfd, fsm->buf, BUFSIZE);
if (fsm->len == 0)
fsm->state = STATE_T; // 读取到文件尾,状态转移至终止态
else if (fsm->len < 0)
{
if (errno == EAGAIN)
fsm->state = STATE_R; // 假错,转移至读取态
else {
fsm->state = STATE_Ex; // 真错,转移至异常态
fsm->errstr = "error in read()";
}
}
else {
fsm->state = STATE_W; // 读取成功,转移至写态
fsm->pos = 0; // 此时buf中所有内容都还没被写入,pos置0
}
break;
case STATE_W:
ret = write(fsm->dfd, fsm->buf + fsm->pos, fsm->len);
if (ret < 0)
{
if (errno = EAGAIN)
fsm->state = STATE_W; // 假错,转移至写态
else {
fsm->state = STATE_Ex; // 真错,转移至异常态
fsm->errstr = "error in write()";
}
}
else
{
fsm->pos += ret; // 写入了ret字节
fsm->len -= ret; // 尚未被写入内容的起始位置后移ret
if (fsm->len == 0)
fsm->state = STATE_R; // 写入完毕,转移至读取态
else {
fsm->state = STATE_W; // 写入未完成,转移至写入态
}
}
break;
case STATE_Ex:
perror(fsm->errstr);
fsm->state = STATE_T;
break;
case STATE_T:
/* do sth */
break;
default:
abort();
break;
}
}
static void relay(int fd1, int fd2) {
// 将文件描述符fd1与fd2都设置为“以非阻塞形式打开”
int fd1_save = fcntl(fd1, F_GETFL);
fcntl(fd1, F_SETFL, fd1_save|O_NONBLOCK);
int fd2_save = fcntl(fd2, F_GETFL);
fcntl(fd2, F_SETFL, fd2_save|O_NONBLOCK);
struct fsm_st fsm12, fsm21; // 两个任务
// 需要两个状态转移过程
// fsm12维护从fd1读写入fd2的状态转移过程
// fsm21维护从fd2读写入fd1的状态转移过程
fsm12.state = STATE_R;
fsm12.sfd = fd1;
fsm12.dfd = fd2;
fsm21.state = STATE_R;
fsm21.sfd = fd2;
fsm21.dfd = fd1; // 初始状态
while (fsm12.state != STATE_T || fsm21.state != STATE_T) {
// 布置监视任务
int nfds = fd1 > fd2 ? (fd1+1):(fd2+1);
fd_set rset, wset;
FD_ZERO(&rset); // 初始化待被监视可读状态的文件描述符集
FD_ZERO(&wset); // 初始化待被监视可写状态的文件描述符集
if (fsm12.state == STATE_R) // 在驱动之前,我们希望监视fsm12任务所维护的两个文件描述符的可读写状态
FD_SET(fsm12.sfd, &rset);
if (fsm12.state == STATE_W)
FD_SET(fsm12.dfd, &wset);
if (fsm21.state == STATE_R) // 在驱动之前,我们希望监视fsm21任务所维护的两个文件描述符的可读写状态
FD_SET(fsm21.sfd, &rset);
if (fsm21.state == STATE_W)
FD_SET(fsm21.dfd, &wset);
// 监视
if (select(nfds, &rset, &wset, NULL, NULL) < 0)
{
if (errno == EINTR) // 假错,代表收到信号
continue; // 因为select会改变传入的文件描述符集,因此需要重新布置监视任务
perror("select()"); // 真错
exit(1);
}
// 查看监视结果
if (FD_ISSET(fsm12.sfd, &rset) || FD_ISSET(fsm12.dfd, &wset) || fsm12.state == 3) // 当fsm12任务中的源fd可读或目标fd可写,才推动fsm12
// 注意,当异常error态(3),也要无条件推动!!
fsm_driver(&fsm12);
if (FD_ISSET(fsm21.sfd, &rset) || FD_ISSET(fsm21.dfd, &wset) || fsm21.state == 3) // 当fsm21任务中的源fd可读或目标fd可写,才推动fsm21
fsm_driver(&fsm21);
}
fcntl(fd1, F_SETFL, fd1_save); // 恢复文件描述符的默认打开方式
fcntl(fd2, F_SETFL, fd2_save);
}
int main() {
int fd1, fd2;
fd1 = open(TTY1, O_RDWR);
if (fd1 < 0)
{
perror("open()");
exit(1);
}
write(fd1, "TTY1\n", 5);
fd2 = open(TTY2, O_RDWR | O_NONBLOCK);
if (fd2 < 0)
{
perror("open()");
exit(1);
}
write(fd2, "TTY2\n", 5);
relay(fd1, fd2); // 数据交换的双方为fd1和fd2
close(fd2);
close(fd1);
exit(0);
}
这样一来,如果终端所对应的文件描述符的可读写状态没有变化,就会阻塞,不会无意义地不断 while 循环。可以看到进程开始运行后 CPU 利用率没什么明显提升,无忙等现象了
思考一下 select 的缺陷有哪些?
- 能监视的状态比较单一,只能监视文件描述符的可读写状态和异常状态(甚至还不能区分不同种类的异常)
- 因为 nfds 形参类型限制,既无法监视值很大的文件描述符,也无法监视太多的文件描述符
- 待被监视的文件描述符由传给形参的实参指定,监视结果也回填至传入的实参。相当于输入与输出共用同一片空间,输出会覆盖输入,很不方便
3.2 poll
可移植,也没那么多缺陷,应该重点掌握。man 2 poll
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
功能:监视文件描述符的事件(即状态的改变)
- fds — 指向存放 pollfd 结构体的数组的首地址。其中,一个 pollfd 结构体就代表了一个监视某文件描述符的某事件的任务。该结构体成员如下:
struct pollfd {
int fd; /* file descriptor */
short events; /* requested events */
short revents; /* returned events */
};
// 其中,events和revents要用位图的视角看待。其所能取的宏值及含义详见man手册
- nfds — 用于指定 fds 所指向的数组中结构体的个数
- 但凡有一个 pollfd 所代表的任务监视到了其指定事件的发生,就返回;否则阻塞
- timeout — 毫秒级的超时设置,该函数最多阻塞的时间为 timeout 所指定的时间。若为 0,则该函数变为非阻塞;若为负,则不设定阻塞时间的上限
代码示例:重构 3.1 中的代码。要求用 poll 替代 select
代码实现如下,详见注释
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <unistd.h>
#include <sys/select.h>
#include <poll.h>
#define TTY1 "/dev/tty2"
#define TTY2 "/dev/tty3"
#define BUFSIZE 1024
enum
{
STATE_R = 1, // 读取态
STATE_W, // 写入态
STATE_Ex, // 异常error态
STATE_T // 终止态
};
struct fsm_st
{
int state; // 状态
int sfd; // 源文件
int dfd; // 目标文件
char buf[BUFSIZE]; // 缓冲区
int len; // 记录缓冲区中读取到的字节数
int pos; // 记录尚未被写入内容的起始位置
char * errstr; // 记录出错信息
};
static void fsm_driver(struct fsm_st* fsm) { // 驱动函数:状态执行一次转换(注意仅转移一次)
int ret;
switch (fsm->state) {
case STATE_R: // 从读取态往别的状态转移
fsm->len = read(fsm->sfd, fsm->buf, BUFSIZE);
if (fsm->len == 0)
fsm->state = STATE_T; // 读取到文件尾,状态转移至终止态
else if (fsm->len < 0)
{
if (errno == EAGAIN)
fsm->state = STATE_R; // 假错,转移至读取态
else {
fsm->state = STATE_Ex; // 真错,转移至异常态
fsm->errstr = "error in read()";
}
}
else {
fsm->state = STATE_W; // 读取成功,转移至写态
fsm->pos = 0; // 此时buf中所有内容都还没被写入,pos置0
}
break;
case STATE_W:
ret = write(fsm->dfd, fsm->buf + fsm->pos, fsm->len);
if (ret < 0)
{
if (errno = EAGAIN)
fsm->state = STATE_W; // 假错,转移至写态
else {
fsm->state = STATE_Ex; // 真错,转移至异常态
fsm->errstr = "error in write()";
}
}
else
{
fsm->pos += ret; // 写入了ret字节
fsm->len -= ret; // 尚未被写入内容的起始位置后移ret
if (fsm->len == 0)
fsm->state = STATE_R; // 写入完毕,转移至读取态
else {
fsm->state = STATE_W; // 写入未完成,转移至写入态
}
}
break;
case STATE_Ex:
perror(fsm->errstr);
fsm->state = STATE_T;
break;
case STATE_T:
/* do sth */
break;
default:
abort();
break;
}
}
static void relay(int fd1, int fd2) {
// 将文件描述符fd1与fd2都设置为“以非阻塞形式打开”
int fd1_save = fcntl(fd1, F_GETFL);
fcntl(fd1, F_SETFL, fd1_save|O_NONBLOCK);
int fd2_save = fcntl(fd2, F_GETFL);
fcntl(fd2, F_SETFL, fd2_save|O_NONBLOCK);
struct fsm_st fsm12, fsm21; // 两个任务
// 需要两个状态转移过程
// fsm12维护从fd1读写入fd2的状态转移过程
// fsm21维护从fd2读写入fd1的状态转移过程
fsm12.state = STATE_R;
fsm12.sfd = fd1;
fsm12.dfd = fd2;
fsm21.state = STATE_R;
fsm21.sfd = fd2;
fsm21.dfd = fd1; // 初始状态
while (fsm12.state != STATE_T || fsm21.state != STATE_T) {
// 布置监视任务,这几行可以放在循环外
struct pollfd pfd[2];
pfd[0].fd = fd1;
pfd[0].events = 0;
pfd[1].fd = fd2;
pfd[1].events = 0;
// fms12是从fd1读数据,写入fd2
// fms21是从fd2读数据,写入fd1
if (fsm12.state == STATE_R) // fsm12为可读态,监视fd1何时可读
pfd[0].events |= POLLIN;
if (fsm12.state == STATE_W) // fsm12为可写态,监视fd2何时可写
pfd[1].events |= POLLOUT;
if (fsm21.state == STATE_R) // fsm21为可读态,监视fd2何时可读
pfd[1].events |= POLLIN;
if (fsm21.state == STATE_W) // fsm21为可写态,监视fd1何时可写
pfd[0].events |= POLLOUT;
// 监视
while (poll(pfd, 2, -1) < 0) {
if (errno == EINTR)
continue;
perror("poll()");
exit(1);
}
// 查看监视结果
// 根据结果推动状态机
if (pfd[0].revents & POLLIN || pfd[1].revents & POLLOUT || fsm12.state == 3) // fd1可读或者fd2可写或者状态机为异常态,无条件推动fsm12
fsm_driver(&fsm12);
if (pfd[0].revents & POLLOUT || pfd[1].revents & POLLIN || fsm21.state == 3) // fd1可写或者fd2可读或者状态机为异常态,无条件推动fsm21
fsm_driver(&fsm21);
}
fcntl(fd1, F_SETFL, fd1_save); // 恢复文件描述符的默认打开方式
fcntl(fd2, F_SETFL, fd2_save);
}
int main() {
int fd1, fd2;
fd1 = open(TTY1, O_RDWR);
if (fd1 < 0)
{
perror("open()");
exit(1);
}
write(fd1, "TTY1\n", 5);
fd2 = open(TTY2, O_RDWR | O_NONBLOCK);
if (fd2 < 0)
{
perror("open()");
exit(1);
}
write(fd2, "TTY2\n", 5);
relay(fd1, fd2); // 数据交换的双方为fd1和fd2
close(fd2);
close(fd1);
exit(0);
}
3.3 epoll
LINUX 的方言,不可移植,理解即可。man 7 epoll 可以查看机制
可以将 epoll 想象成一个位于内核的监视工具。通过 epoll 监视文件描述符的流程如下:
- 通过 epoll_create 在内核创建一个 epoll 实例(相当于创建一个监视工具)
- 通过 epoll_ctl 向 epoll 实例中添加/修改/删除监视任务
- 通过 epoll_wait 等待其所监视的文件描述符上的事件
- 当不再需要 epoll 实例时,需要通过 close 关闭它
select、poll 都需要在用户态确定希望被监视的 fd 的集合。然后在监视的时候需要将该集合复制到内核空间中,这样内核才能帮助我们轮询 fd,这个过程具有一定开销;而 epoll 直接往内核去添加希望被监视的 fd,去除了复制过程的开销
select、poll 都需要不断遍历所有的 fd 来获取就绪的文件描述符,时间复杂度高;而 epoll 在文件就绪后会触发回调,然后将就绪的 fd 放入就绪链表中。这样一来,只需要从就绪链表中获取就绪的文件描述符,时间复杂度低
3.3.1 epoll_create
man 2 epoll_create
#include <sys/epoll.h>
int epoll_create(int size);
功能:创建一个 epoll 实例(即创建一个监视工具)
- size — LINUX 2.6.8 以后,size 无意义了,只要传一个正数即可
- 成功返回一个文件描述符,用于表征该 epoll 实例;失败返回 -1 并设置 errno
3.3.2 epoll_ctl
man 2 epoll_ctl
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
功能:向 epoll 实例中添加/修改/删除监视任务
- epfd — 这是 epoll_create 成功后所返回的文件描述符,表征一个 epoll 实例(监视工具)
- fd — 指定一个文件描述符
- op — 指定 epoll_ctl 的具体行为。其值及含义如下
值 | 含义 |
---|---|
EPOLL_CTL_ADD | 注册监视任务:让监视工具开始监视 fd。默认监视的事件为 event 所指定的事件。可以往一个监视工具上注册多个监视任务 |
EPOLL_CTL_MOD | 修改监视任务:修改监视 fd 上的事件为 event 所指定的事件 |
EPOLL_CTL_DEL | 取消监视任务:让监视工具停止监视 fd |
- event — 指向 struct epoll_event 类型的结构体,表示要监视的事件。结构体的成员如下:
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
// 其中,events要用位图的视角看待。其所能取的宏值及含义详见man手册
// data是epoll_data_t类型的结构体,可由用户自定义其字段的含义,常用来储存一些与监视任务有关的信息
附:epoll 实例中的任务的组织形式
3.3.3 epoll_wait
man 2 epoll_wait
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
功能:等待事件发生
- epfd — 这是 epoll_create 成功后所返回的文件描述符,表征一个 epoll 实例(监视工具)
- events — 指向结构体数组,当某个任务所监视的 fd 及其关注的事件发生后,函数返回并将监视结果及与该任务有关的一些信息回填至该数组。因为可能有多个任务监视到事件的发生,所以我们才用数组收集结果,数组空间由调用者负责申请
- maxevents — 指定 events 所指向的数组大小
- timeout — 类似于 select 中的 timeout。如果没有任务监视到事件的发生,则 epoll_wait 会阻塞 timeout 毫秒。如果 timeout 设为 -1,则 epoll_wait 会一直阻塞,直到有任务监视到事件发生;如果 timeout 设为 0,则 epoll_wait 会立即返回
- 返回值表示 events 中存储的元素个数,表示有多少个任务监视到了事件的发生。最大不超过 maxevents
3.3.4 代码示例
代码示例:重构 3.1 中的代码。要求用 epoll 替代 select
代码实现如下,详见注释
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <errno.h>
#include <unistd.h>
#include <sys/select.h>
#include <sys/epoll.h>
#define TTY1 "/dev/tty2"
#define TTY2 "/dev/tty3"
#define BUFSIZE 1024
enum
{
STATE_R = 1, // 读取态
STATE_W, // 写入态
STATE_Ex, // 异常error态
STATE_T // 终止态
};
struct fsm_st
{
int state; // 状态
int sfd; // 源文件
int dfd; // 目标文件
char buf[BUFSIZE]; // 缓冲区
int len; // 记录缓冲区中读取到的字节数
int pos; // 记录尚未被写入内容的起始位置
char * errstr; // 记录出错信息
};
static void fsm_driver(struct fsm_st* fsm) { // 驱动函数:状态执行一次转换(注意仅转移一次)
int ret;
switch (fsm->state) {
case STATE_R: // 从读取态往别的状态转移
fsm->len = read(fsm->sfd, fsm->buf, BUFSIZE);
if (fsm->len == 0)
fsm->state = STATE_T; // 读取到文件尾,状态转移至终止态
else if (fsm->len < 0)
{
if (errno == EAGAIN)
fsm->state = STATE_R; // 假错,转移至读取态
else {
fsm->state = STATE_Ex; // 真错,转移至异常态
fsm->errstr = "error in read()";
}
}
else {
fsm->state = STATE_W; // 读取成功,转移至写态
fsm->pos = 0; // 此时buf中所有内容 都还没被写入,pos置0
}
break;
case STATE_W:
ret = write(fsm->dfd, fsm->buf + fsm->pos, fsm->len);
if (ret < 0)
{
if (errno = EAGAIN)
fsm->state = STATE_W; // 假错,转移至写态
else {
fsm->state = STATE_Ex; // 真错,转移至异常态
fsm->errstr = "error in write()";
}
}
else
{
fsm->pos += ret; // 写入了ret 字节
fsm->len -= ret; // 尚未被写入内容的起始位置后移ret
if (fsm->len == 0)
fsm->state = STATE_R; // 写入完毕,转移至读取态
else {
fsm->state = STATE_W; // 写入未完成,转移至写入态
}
}
break;
case STATE_Ex:
perror(fsm->errstr);
fsm->state = STATE_T;
break;
case STATE_T:
/* do sth */
break;
default:
abort();
break;
}
}
static void relay(int fd1, int fd2) {
// 将文件描述符fd1与fd2都设置为“以非阻塞形式打开”
int fd1_save = fcntl(fd1, F_GETFL);
fcntl(fd1, F_SETFL, fd1_save|O_NONBLOCK);
int fd2_save = fcntl(fd2, F_GETFL);
fcntl(fd2, F_SETFL, fd2_save|O_NONBLOCK);
struct fsm_st fsm12, fsm21; // 两个任务
// 需要两个状态转移过程
// fsm12维护从fd1读写入fd2的 状态转移过程
// fsm21维护从fd2读写入fd1的 状态转移过程
fsm12.state = STATE_R;
fsm12.sfd = fd1;
fsm12.dfd = fd2;
fsm21.state = STATE_R;
fsm21.sfd = fd2;
fsm21.dfd = fd1; // 初始状态
struct epoll_event ev;
int epfd = epoll_create(10); // 创建epoll实例,参数大于0即可
if (epfd < 0){
perror("epoll_create()");
exit(1);
}
epoll_ctl(epfd, EPOLL_CTL_ADD, fd1, &ev); // 注册监视任务:使epoll实例开始监视fd1
epoll_ctl(epfd, EPOLL_CTL_ADD, fd2, &ev); // 注册监视任务:使epoll实例开始监视fd2
while (fsm12.state != STATE_T || fsm21.state != STATE_T) {
// 布置监视任务,我们写在了while循环外
// fms12是从fd1读数据,写入fd2
// fms21是从fd2读数据,写入fd1
ev.data.fd = fd1; // 记录与监视任务有关的一些信息,这里表示该任务监视的fd1
ev.events = 0;
if (fsm12.state == STATE_R) // fsm12为可读态,监视fd1何时可读
ev.events |= EPOLLIN;
if (fsm21.state == STATE_W) // fsm21为可写态,监视fd1何时可写
ev.events |= EPOLLOUT;
epoll_ctl(epfd, EPOLL_CTL_MOD, fd1, &ev); // 指定监视fd1上的事件为ev所指定的事件
ev.data.fd = fd2; // 记录与监视任务有关的一些信息,这里表示该任务监视的fd2
ev.events = 0;
if (fsm12.state == STATE_W) // fsm12为可写态,监视fd2何时可写
ev.events |= EPOLLOUT;
if (fsm21.state == STATE_R) // fsm21为可读态,监视fd2何时可读
ev.events |= EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_MOD, fd2, &ev); // 指定监视fd1上的事件为ev所指定的事件
// 监视
while (epoll_wait(epfd, &ev, 1, -1) < 0) { // 通过ev获取监视到事件的任务的返回信息
if (errno == EINTR)
continue;
perror("epoll_wait()");
exit(1);
}
// 当执行到此处,必定说明已经有某个任务监视到了其所关注的事件,监视结果及该任务相关信息已经被回填至ev
// 查看监视结果,ev.events存放了监视结果;ev.data存放了该任务相关信息(如该任务监视的哪个文件描述符)
// 根据结果推动状态机
if (ev.data.fd == fd1 && ev.events & EPOLLIN || ev.data.fd == fd2 && ev.events & EPOLLOUT || fsm12.state == 3) // fd1可读或者fd2可写或者状态机为异常态,无条件推动fsm12
fsm_driver(&fsm12);
if (ev.data.fd == fd1 && ev.events & EPOLLOUT || ev.data.fd == fd2 && ev.events & EPOLLIN || fsm21.state == 3) // fd1可写或者fd2可读或者状态机为异常态,无条件推动fsm21
fsm_driver(&fsm21);
}
fcntl(fd1, F_SETFL, fd1_save); // 恢复文件描述符的默认打开方式
fcntl(fd2, F_SETFL, fd2_save);
close(epfd); // 不再需要epoll实例,关闭它
}
int main() {
int fd1, fd2;
fd1 = open(TTY1, O_RDWR);
if (fd1 < 0)
{
perror("open()");
exit(1);
}
write(fd1, "TTY1\n", 5);
fd2 = open(TTY2, O_RDWR | O_NONBLOCK);
if (fd2 < 0)
{
perror("open()");
exit(1);
}
write(fd2, "TTY2\n", 5);
relay(fd1, fd2); // 数据交换的双方为fd1和fd2
close(fd2);
close(fd1);
exit(0);
}
四、其他读写函数
4.1 readv
man 2 readv
#include <sys/uio.h>
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
功能:读文件描述符并将结果写入离散空间
- fd — 指定读哪个文件描述符
- iov — 指向一个用来存放 iovec 结构体的数组。每个结构体表示其中一块离散空间
- iovcn — 用来描述数组长度,表示有多少块离散空间
其中,iovec 结构体的内容如下
struct iovec {
void *iov_base; /* Starting address */
size_t iov_len; /* Number of bytes to transfer */
};
4.2 writev
man 2 writev
#include <sys/uio.h>
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
功能:将离散空间的数据写入文件描述符
- fd — 指定写哪个文件描述符
- iov — 指向一个用来存放 iovec 结构体的数组。每个结构体表示其中一块离散空间
- iovcn — 用来描述数组长度,表示有多少块离散空间
五、存储映射 IO
5.1 简介
mmap 的核心思想是将文件的内容直接映射到进程的内存地址空间中,让文件数据的访问更接近于直接访问内存的高效率,而无需传统的读写系统调用
man 2 mmap
#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
int munmap(void *addr, size_t length);
功能:将文件的内容直接映射到进程的内存地址空间中
- addr — 指定将文件的内容映射到哪个位置(即指定映射区域的地址)。通常设置为 NULL,表示由操作系统自动选择一个地址
- length — 希望映射到内存的目标文件的长度。这个长度是以字节为单位的,它决定了映射区域的大小
- prot — 决定了进程能对映射区域做哪些类型的访问,详见 man
- flags — 控制映射类型和选项的标志,详见 man
- fd — 指定希望映射哪个文件的内容
- offset — 指定希望从文件的哪个位置开始映射
- 成功返回映射区域的地址;失败返回 MAP_FAILED 并设置 errno
代码示例:通过 mmap 统计某个文件中某个字母的个数
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
int main(int argc, char ** argv) {
if (argc < 3)
{
fprintf(stderr, "Usage: %s <filename> <alpha>\n", argv[0]);
exit(1);
}
int fd = open(argv[1], O_RDONLY);
if (fd < 0)
{
perror("open()");
exit(1);
}
struct stat statbuf;
if(stat(argv[1], &statbuf) < 0)
{
perror("state()");
exit(1);
}
// 操作系统自动选择将fd映射到哪个位置,映射整个文件内容,需要映射区域的大小就是文件大小
// 进程对映射区域可读,且对映射区域的修改会反映到被映射的文件中
// 返回映射区域的首地址,我们用char*接收,这样一来可以像操作字符串一样操作文件内容
char * str = mmap(NULL, statbuf.st_size, PROT_READ, MAP_SHARED, fd, 0);
if (str == MAP_FAILED)
{
perror("mmap()");
exit(1);
}
close(fd); // 已经映射过来了,fd就不用了
long int count = 0;
for (int i = 0; i < statbuf.st_size; ++i)
{
if (str[i] == argv[2][0])
count++;
}
printf("%ld\n", count);
munmap(str, statbuf.st_size); // 撤销通过mmap创建的内存映射
exit(0);
}
5.2 共享内存用作进程间通信
基本思路是,将同一个文件映射到两个不同进程。此时这个文件就像一个“共享内存”
我们实现一个基于共享内存进行父子进程通信的示例
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <unistd.h>
#include <string.h>
#define MEMSIZE 1024
int main()
{
// 让系统自己找映射区域的地址
// 因为要通信,因此需要能对这片区域可读可写
// 因为要通信,因此对这片区域的修改应该能够反映到被映射的文件
// 采用匿名映射就不用去指定特定文件了,比较方便。此时传-1给fd参数即可
char * ptr = mmap(NULL, MEMSIZE, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0);
if (ptr == MAP_FAILED)
{
perror("mmap()");
exit(1);
}
pid_t pid = fork(); // 映射区域也被复制了,相当于父子进程的映射区域都映射的相同目标
if (pid < 0)
{
perror("fork()");
munmap(ptr, MEMSIZE);
exit(1);
}
if (pid == 0) // child write
{
strcpy(ptr, "Hello!"); // 此时只要将文件内容当作字符串即可
munmap(ptr, MEMSIZE);
exit(0);
}
else // parent read
{
wait(NULL);
puts(ptr);
munmap(ptr, MEMSIZE);
exit(0);
}
exit(0);
}
子进程写进共享内存的字符串成功在父进程中打印了出来,通信成功!
六、文件锁
之前介绍过多线程里面的 pthread_mutex 锁,那个锁是在单一进程内防止线程之间出现资源访问竞争
而文件锁用于同步多个进程对同一文件执行的 IO 操作,防止进程之间出现文件访问竞争
有多个函数能够实现文件锁,我们只介绍一部分
6.1 lockf
man 3 lockf
#include <unistd.h>
int lockf(int fd, int cmd, off_t len);
功能:对某个文件的特定部分进行锁操作(上锁、解锁、测试是否上锁)
- fd — 用来指定对哪个文件进行锁操作
- len — 用来指定取文件的哪部分并对其进行锁操作。当 len > 0,则从pos 指针所指位置起取 len 字节;当 len < 0,则取 pos 所指位置之前的 len 字节;当 len = 0,则从 pos 所指位置起取到文件末尾(哪怕文件末尾位置一直在不断变化)
- cmd — 决定具体对指定文件的指定部分做什么样的锁操作。其值及含义如下
值 | 含义 |
---|---|
F_LOCK | 上锁。若该部分已被其他进程上锁,则阻塞直到被解锁 |
F_TLOCK | 尝试上锁。若该部分已被其他上锁,不阻塞并返回一个错误 |
F_ULOCK | 对某部分解锁 |
F_TEST | 测试某部分是否上锁。未上锁返回 0;上锁返回 -1 并设置 errno 为 EAGAIN |
注意:close 一个文件描述符会解开其对应文件上当前进程拥有的锁(man close)
而锁是和文件关联的,两个不同的文件描述符可能表征相同的文件,如下图所示。因此,如果此时 close 其中一个文件描述符 fd2,则其对应的文件会解锁。从 fd1 的视角看,相当于一个意外解锁:明明没有通过 fd1 调用 ulock,fd1 所对应的文件还是被解锁了
代码示例:多进程同时操作一个文件。每个进程都会:
- 从文件中获取值
- 将值加一
- 用加一后的值替换文件中原来的值
不上锁的版本:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <wait.h>
#include <sys/types.h>
#define PROCNUM 2000 //创建2000个进程
#define LINESIZE 1024
#define FILENAME "./tmp"
static void func_add(void) {
FILE * fp = fopen(FILENAME, "r+");
if (fp == NULL)
{
perror("fopen()");
exit(1);
}
char linebuf[LINESIZE];
fgets(linebuf, LINESIZE, fp); // 获取
fseek(fp, 0, SEEK_SET); // 将文件位置指针pos定位到文件首,这样才能实现覆盖原值
fprintf(fp, "%d\n", atoi(linebuf)+1); // 加一后写回去
fflush(fp);
fclose(fp);
}
int main() {
pid_t pid;
for (int i = 0; i < PROCNUM; ++i) { // 父进程不断创建子进程
pid = fork();
if (pid < 0) // error
{
perror("fork()");
exit(1);
}
else if (pid == 0) // child do sth
{
func_add(); // do sth
exit(0);
}
else // parent continue to fork
{
continue;
}
}
for (int i = 0; i < PROCNUM; ++i) { // 对PROCNUM个进程收尸
wait(NULL);
}
exit(0);
}
按理来说,2000 个进程每个都要取值加一,应该每次都会增加 2000 的,为什么结果不一致?
答案还是因为多进程竞争
因此我们需要用到文件锁,保证多个进程不能同时操作单个文件
上锁版本如下:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <wait.h>
#include <sys/types.h>
#define PROCNUM 2000 //创建2000个进程
#define LINESIZE 1024
#define FILENAME "./tmp"
static void func_add(void) {
FILE * fp = fopen(FILENAME, "r+");
if (fp == NULL)
{
perror("fopen()");
exit(1);
}
char linebuf[LINESIZE];
int fd = fileno(fp); // 我们对文件上锁需要用到文件描述符fd,需要从FILE结构体中找到fd
if (lockf(fd, F_LOCK, 0) < 0)
{
perror("lockf()");
exit(1);
}
fgets(linebuf, LINESIZE, fp); // 获取
fseek(fp, 0, SEEK_SET); // 将文件位置指针pos定位到文件首,这样才能实现覆盖原值
fprintf(fp, "%d\n", atoi(linebuf)+1); // 加一后写回去
fflush(fp);
lockf(fd, F_ULOCK, 0); // 解锁
fclose(fp);
}
int main() {
pid_t pid;
for (int i = 0; i < PROCNUM; ++i) { // 父进程不断创建子进程
pid = fork();
if (pid < 0) // error
{
perror("fork()");
exit(1);
}
else if (pid == 0) // child do sth
{
func_add(); // do sth
exit(0);
}
else // parent continue to fork
{
continue;
}
}
for (int i = 0; i < PROCNUM; ++i) { // 对PROCNUM个进程收尸
wait(NULL);
}
exit(0);
}
我们可以看到,2000 个进程每个都要取值加一,因此每次都会增加 2000,成功!
6.2 flock
功能类似 lockf,也能实现文件锁操作,略
6.3 fcntl
也能实现文件锁操作,略
为什么更新速度慢了?因为导师得知本人找到工作后又开始安排项目了/(ㄒoㄒ)/~~
等等,为什么研三还在做项目!!!