目录
一、五大 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ㄒ)/~~
等等,为什么研三还在做项目!!!