CarterLi

CarterLi 查看完整档案

上海编辑中北大学  |  软件学院 编辑EOI  |  前端研发经理 编辑 www.eoitek.com 编辑
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 该用户太懒什么也没留下

个人动态

CarterLi 发布了文章 · 9月13日

用个 EventBus 还得找第三方库?直接使用原生实现,B格瞬间提升一级

Vue 3 大改了事件绑定机制,事件绑定变成了简单的属性传递,$on$off 也没了,Vue 对象完全失去了 EventBus 的功能。尤大最后安利了一个第三方库,用个 EventBus 发个事件还要第三方库?这也太 low 了吧。

​JavaScript 本来原生就提供了事件绑定机制,前端开发都用了无数遍了,就是 addEventListener 。​addEventListener 是类 EventTarget 的成员方法,我们可以给一个按钮绑定 click 事件,实际上是因为按钮的原型类 HTMLButtonElement 继承自 EventTarget

image

原生的 EventBus

不同于大多数 DOM 原型类,EventTarget 可以直接 new,但是仅在较新的浏览器中才支持。我们也不需要用元素这种重量级的东西。从上面的截图可以看到,Node 就继承自 EventTarget,所以所有 DOM 节点都有 addEventListener,这里推荐一种足够轻量级的节点:注释

对于浏览器渲染引擎来说,注释就是一段解释性的文字,没有任何作用,纯粹给开发者看的。但是注释写在 HTML 脚本中,它仍然是一个节点,有自己的原型类 Comment,并且继承自 Node

Comment 类可以直接 new,也可以使用比较老的但是兼容性更好的方式 document.createComment,它强制要求一个参数,是注释的内容。你可以写一些描述性的信息,如果没什么可说的,直接用空字符串也可以。

const myEventBus = document.createComment('my-event-bus');

有了 EventBus 实例​就可以触发事件了。触发事件使用 dispatchEventdispatchEvent 接收一个 Event 对象,但是通常的 Event 对象不能传递用户的自定义数据,这时可以用 CustomEvent,它的 detail 属性可以存储任意数据。

myEventBus.dispatchEvent(new CustomEvent('event-name', { detail: 'event-data' }));

需要注意的是,虽然 IE9+ 都支持 CustomEvent,但是包括 IE11 在内都不支持 new CustomEvent。虽然可以用 document.createEvent 但是操作复杂,建议用一下 Polyfill

不过话说 Vue 3 都不想支持 IE 了我们纠结那么多干嘛。。。

最后就可以接收事件了

myEventBus.addEventListener('event-name', ({ detail }) => {
    console.log(detail); // => event-data
});

新版浏览器中 addEventListener还可以使用 { once: true } 实现单次绑定

关于调试

项目复杂之后,绑定的事件会越来越多,还可能出现绑定过后忘了解绑的bug。如果我想看看 myEventBus 上面绑定了哪些事件应该怎样看呢?

myEventBus 是一个 DOM 节点,所以它可以用浏览器的元素查看器调试

首先将其追加到 DOM 树上。往页面中添加注释通常不会对原页面产生影响。

document.appendChild(myEventBus)

这时就可以在 DOM 树的末尾找到这个注释节点,可以直接查看

image.png

你还能找到绑定事件代码的位置,还可以直接删除某个事件绑定。是不是很方便?

查看原文

赞 1 收藏 0 评论 0

CarterLi 发布了文章 · 8月23日

神奇的 JavaScript 之 bind.bind.bind

事情起源于一段JS代码:

function bind(func, context) {
    var args = nativeSlice.call(arguments, 2);
    return function () {
        return func.apply(context, args.concat(nativeSlice.call(arguments)));
    };
}

源代码出自 echarts 的底层依赖 zrender,为了优化我的 offscreen-echarts 库我在读 echarts 的源码,于是就发现了这段代码。

干嘛要自己重新实现一遍 bind,难道还有浏览器不支持?于是去查了一下 MDN

image

好吧,果然是我大 IE8。想到 echarts 里大量的 VML 代码,我大百度还是对 IE8 用户这类濒危物种放心不下啊。但是看这个 bind 实现又是 apply 又是 concat 又是 slice 又是 call 的,性能肯定好不到哪去。

但是干嘛非要让我们这些 Chrome 用户为 IE 这种糟粕付出代价呢?

不行,非得改改不可(重度强迫症患者)。然而代码都已经用了 bind(f, xxx) 而不是 f.bind(xxx)function bind 是逃不掉了,你只能用原生的 Function.prototype.bind 去优化这个 function bind

仔细观察这个 function bind 的用法

bind(f, {}, 1, 2, 3)(4, 5, 6);

与原生 Function.prototype.bind 用法

f.bind({}, 1, 2, 3)(4,5,6);

的区别,function bind 把原生 bindthis 作为参数传了,这不就是 call 吗?所以可以改写为

Function.prototype.bind.call(f, {}, 1, 2, 3)(4, 5, 6);

所以我们的 function bind 就是 Function.prototype.bind.call,只不过它的 thisFunction.prototype.bind。改变一个函数的 this 需要 bind,所以就有

var bind = Function.prototype.bind.call.bind(Function.prototype.bind);

Function.prototype.bind.call.bindcallthis 已经后面由 bind 指定了,call 前面的 this 已经失去了意义,所以其等价于

var bind = Function.prototype.call.bind(Function.prototype.bind);

我们需要在浏览器支持原生 bind 的前提下用新实现覆盖原始的 function bind,所以改写为

if (bind.bind) {
    bind = bind.call.bind(bind.bind);
}

bind.bind === Function.prototype.bind,和 [].slice === Array.prototype.slice 一个意思。

这里其实已经是最佳实现了,因为只是把原生 bind 使用 call 方法调用,性能几乎等同于原生 bind 的性能。如果把参数 bind.bind 移到函数前面再 bind 一层,就变成

if (bind.bind) {
    bind = bind.bind.bind(bind.call);
}

于是就出现了 bind.bind.bind。但是因为这里其实有两层 bind 所以实际性能是有损耗的:https://jsben.ch/sEcop

image

查看原文

赞 0 收藏 0 评论 1

CarterLi 发布了文章 · 2019-12-21

JavaScript:关于数组的四道面试题

已知后端返回一个对象数组,格式类似这样:

const arr = [
  { id: 1, name: 'a', birth: 896630400000 },
  { id: 2, name: 'b', birth: 725817600000 },
  ...,
]

按要求写代码:

  1. 按照 name 属性降序排序(字母顺序从大到小)
  2. 去除 id 属性相同的元素(如出现重复,后出现的覆盖先出现的,不要求保留原始顺序)
  3. 过滤出所有的95后(birth >= 1995年1月1日)
  4. 如何做前端分页

由于公司是大数据处理业务,数组相关操作使用还是挺频繁的。这是我们公司前端JS面试题中的编程题部分。虽然都是基础,但是答上来的似乎不多。下面我们依次分析。

  • 在看答案前不自己先试试么
  • 在看答案前不自己先试试么
  • 在看答案前不自己先试试么
  • 在看答案前不自己先试试么
  • 在看答案前不自己先试试么
  • 在看答案前不自己先试试么
  • 在看答案前不自己先试试么
  • 在看答案前不自己先试试么
  • 在看答案前不自己先试试么
  • 在看答案前不自己先试试么
  • 在看答案前不自己先试试么

数组排序

  • 按照 name 属性降序排序(字母顺序从大到小)

看到排序当然可以联想到 Array#sort。大家都写过 [1, 3, 2].sort((a, b) => a - b),但是这个数组有点特殊,它是对象数组。排序规则也有些特殊:是按照字符串排序,而且是降序排序。

考点:

  1. Array#sort
  2. String#localCompare

Array#sort 这个这个毫无疑问。手写排序算法的人会被一概刷掉。难点在于在 sort 的回调函数中如何比较字符串的大小。首先字符串相减是不对的,对于 'a'-'b' 这个表达式,JS 引擎会首先把字符串 'a''b' 转换为数字,因为 JS 标准规定只有数字才能相减。于是:

+'a' // => NaN
+'b' // => NaN
'a' - 'b' = NaN - NaN = NaN

有些同学会提出挨个比较字符编码的方法,此法虽然可行,但是你要知道字符串长度是不固定的,你必须判断前一位相等后比下一位,于是不得不引入循环。这并不是面试官想要的答案。

虽然字符串不可相减,但是字符串却可以用大于小于号比较大小,而且比较的方式就是字典序依次比较。

'a' < 'b' // true
'a' < 'a' // false
'aa' < 'ab' // true

使用比较运算符比较字符串的问题就是你无法一次性得到他们的准确关系。如果 a < b 返回 false,还有两种可能,一种是 a 等于 b,另一种是 a 大于 b。所以如果使用比较运算符,你就必须在 sort 的回调函数中判断两次。

arr.sort((a, b) => a.name < b.name ? 1 : a.name > b.name ? -1 : 0); // 记得是逆序

最优方案是使用 String#localeCompare,这个函数可以一次性返回正负零,完美适用于 Array#sort

arr.sort((a, b) => b.name.localeCompare(a.name)); // 逆序,所以 b 在 a 前面

数组去重

  • 去除 id 属性相同的元素(如出现重复,后出现的覆盖先出现的,不要求保留原始顺序)

网上看过一些面试题的同学,看到数组去重肯定马上会联想到 Set,这个玩意用来做数组去重简直是帅到没朋友

[...new Set([1, 1, 2, 3, 3, 1])] // => [1, 2, 3]

然而这道题却不按套路出牌:它要去重的是对象数组,不是简单的数字数组。

遇到这道题时可以按照这样的思路来:题目说需要按照字段 id 去重,那么 id 是必需拿出来单独处理的。但是 id 去重却不是去 id 本身,而是去 id 所关联的对象,那么肯定需要一个 id 到对象的映射。想到映射这一层,那么很容易联想到对象和 Map

考点:

  1. 对象 key 的唯一性
  2. Map

如果使用对象,笨一点的话可以这样:

const map = {};
const resultArr = [];

arr.forEach(x => {
  if (!map[x]) {
    map[x] = true;
    resultArr.push(x);
  }
}

这种写法不完全符合题设要求,它总会保留最先出现的值。如果要保留后出现的值,可以把数组先反转再遍历。

arr.concat().reverse().forEach(...) // reverse 会改变数组的值,用之前需先克隆原始数组

这里可能有同学会提到 reduceRight,这里建议如果你不能保证回调函数为纯函数,请不要使用 map 或 reduce。

另外一个方式是始终用后面的对象覆盖前面的。

const map = {};
arr.forEach(x => {
  map[x] = x;
}

const resultArr = [];
for (let key in map) {
  // 严谨的话,这里要用 hasOwnProperty 去除父级对象上的 key
  resultArr.push(map[key]);
}

分为两步,前一步有些类似把数组转换为对象,后一步就是取一个对象里的所有值。前者可以用 Object#assignObject#fromEntries 代替,后者就是 Object#values。所以简洁的写法是这样:

Object.values(Object.assign(...arr.map(x => ({ [x.id]: x }))));

对象其实不是一个完美的解决方案,因为对象的 key 都会被强制转换为字符串,虽然题设中的 id 都是数字类型,但是难保后面会出现字符串的数字。完美的解决方案是 Set 的同胞兄弟 Map

[...new Map(arr.map(x => [x.id, x])).values()] // 注意 Map#values 返回的不是数组而是一个迭代器,需要手工再转为数组

数组过滤

  • 过滤出所有的95后(birth >= 1995年1月1日)

考点:

  1. Array#filter
  2. Date

此题明显比前面两题简单不少,但此题却很少有人完全答对,问题就出在日期对象的使用上

我在之前的博文 说过 Date 对象的坑:当你使用数字年月日构造 Date 对象时记得月份是从 0 开始的;当你使用字符串构造 Date 对象时请使用斜杠(/)避免出现时区问题。

arr.filter(x => x.birth >= new Date(1995, 0, 1);

前端分页

  • 如何做前端分页

考点:

  1. Array#slice
  2. 分页的基本概念

所谓“分页”就是从数组中截取要显示的部分放到页面上显示,截取就是 slice,但似乎好多人把 slice 和 splice 搞混。

slice 是截取数组的一部分,但不改变原数组,两个参数都是下标。splice 虽然也可以作为截取数组使用,但 splice 会改变原数组,而且 splice 要求的第二个参数是截取项的长度。slice 就是截取数组,而 splice 通常用作数组中部的插入删除操作。

slice 和 splice,中间差了一个字母 p,用法含义大不相同。

const from = (页码 - 1) * 每页条数;
arr.slice(from, from + 每页条数);

数组对象有很多的原生方法,数组操作是 JS 数据操作中最常用的操作,经常温习一下没错的。

查看原文

赞 5 收藏 4 评论 0

CarterLi 发布了文章 · 2019-06-16

文件系统与异步操作——异步IO那些破事

为什么想起写这篇文章

前面这篇文章提到,旧的 Linux AIO 只支持直接(Direct)IO,还对读写区域大小有限制,但是 Windows 上的 IOCP 就有完整的 AIO 支持。之前真的觉得 Windows 真的很牛B,但是对为什么这样一直懵懵懂懂。

直到昨天我看到了这篇讨论帖:https://news.ycombinator.com/...,他说微软的异步IO是用线程模拟的。

WTF?这个内核原生支持这么高大上的东西居然是模拟的?但是人家还拿了微软官方的文章佐证

... so if you issue an asynchronous cached read, and the pages are not in memory, the file system driver assumes that you do not want your thread blocked and the request will be handled by a limited pool of worker threads.

微软官方的说明总不会错。但为什么会这样?缓冲(Buffered)IO实现异步为什么就这么难?

还得从硬件说起

回顾一下大学学的计算机硬件知识:https://www.cnblogs.com/jswan...

盘面、磁道、扇区

每个硬盘有多个盘面,每个盘面都是由一圈圈的同心圆组成的,叫做磁道(track)。每个磁道又被等比划分为若干个弧段,叫做扇区(sector)。扇区有固定的大小和个数,而且从硬盘诞生就被分配在固定位置。一般一个扇区具体的大小视总磁盘大小而定,传统上512B为一个扇区(但是也有不同)。

扇区

注:现在的固态硬盘已经没有传统意义上的盘面、磁道概念,但是扇区的概念一直保留了下来。

扇区是实际上的磁盘最小读写单位,操作系统与磁盘文件系统通信必须以扇区的整数个进行。这里的整数个不仅代表大小,而且指个体,例如你不能只读第一个扇区的后半个+第二个扇区的前半个,虽然加起来大小也是一个扇区。

直接(Direct)IO,最原始的文件IO

这种基于扇区操作磁盘的方式就直接派生了一种文件IO方式——直接(Direct)IO,也叫裸(Raw)IO,也叫非缓存(Unbuffered)IO。在 *nix 系中对应 O_DIRECT,在 Windows 中对应 FILE_FLAG_NO_BUFFERING

对于这种IO方式,读写操作都有硬性的要求,所有操作系统都一致:

  1. 数据传输的开始点,即文件和设备的偏移量,必须是扇区大小的整数倍
  2. 待传递数据的长度必须是扇区大小的整数倍
  3. 用于传递数据的缓冲区,其内存边界必须对齐为扇区大小的整数倍

前两个限制就是为了保证读写的都是一个个完整的扇区。第一个限制要求从一个扇区的开始读写,第二个限制要求正好到一个扇区完截止。第三个限制则是为了保证读写一个扇区时不发生页错误(page fault)

直接内存访问(DMA),现代文件IO机制,异步IO的基础

这些硬性要求都保证了,那么操作系统怎样实施文件IO操作呢?

这里以读为例。就是磁盘说:把从第x号扇区开始的y个扇区的数据写入到从p地址开始的内存中,写完了告诉我(触发中断)。

这种操作叫做直接内存访问(Direct memory access),其整个过程都不需要CPU参与。这个过程中CPU有两个选择:等待或者去做其他事。前者就是同步IO,后者就是异步IO。所以异步IO中实现直接IO是毫无问题的。

缓冲IO,操作方便性的妥协

直接IO环节少速度快。但是限制太多太复杂了,人们不想用。

设想一下,如果用户想把文件中间的某个部分读取至内存,使用直接IO需要怎么做?假设用户需要读取 size 个字节,开始位置为 offset,需要读取至的内存指针为 p

// 首先获取扇区大小
int sectsize;
ioctl(fd, BLKSSZGET, &sectsize);
// 存储真实需要读取的数据量
size_t actual_size = size;
// 需要读取的开始部分
size_t start = offset;
// 需要读取的第一个扇区可能不完整
if (offset % sectsize != 0) {
    // 需要找到这段数据是从哪个扇区开始存储的(相对于文件头),从扇区的开始位置读取
    start -= offset % sectsize;
    // 把需要多读的数据量计算进去
    actual_size += start - offset;
}
// 需要读的最后一个扇区也可能不完整
if (actual_size % sectsize != 0) {
    // 需要读满整个尾扇区
    actual_size += sectsize - actual_size % sectsize;
}
// 终于算出了所需要的所有参数,开辟临时内存
void *buf = aligned_alloc(sectsize, actual_size); // aligned_alloc 保证申请的内存地址按指定字节数对齐
// 执行读操作,可以异步
pread(fd, buf, actual_size, start);
// 将读出的数据中所需要的部分复制到指定内存
memcpy(p, ((char *)buf + (start - offset)), size);
// 释放零时内存
free(buf);

这是读操作,写操作更复杂。为了保证填补区域空间不被写操作冲掉,你要先把填补空间的数据从文件里读出来。

为了简化用户端操作,所有的内核都提供基于缓冲机制(类似如上操作)的IO操作方式,这就是缓冲(Buffered)IO

缓冲IO——异步IO的原罪

前面说到异步IO中实现直接IO毫无问题,因为直接IO一旦开始全程不需要CPU参与。但是缓冲IO不一样了,还是以上面的读操作举例,申请内存可以在打开文件时做,释放内存可以在关闭文件时做,数据对齐操作是纯计算而且本身计算量不大同步做了也无所谓。但是最后的内存复制和这次读操作强关联,无论如何省不掉。

那么怎么办呢?内核为了不阻断当前线程运行,必须开辟一个线程池等待所需的直接IO操作完成,然后做如上内存复制操作。线程池中线程的数量是有限的,如果程序同时发起了大量异步的缓存IO请求,导致内核中的线程不够用,那么本次异步操作会被放入等待队列等待线程池空闲,或者干脆直接改为阻塞执行

Windows、Linux、*BSD,无一例外。旧的 Linux AIO 最直接,遇到缓存IO直接强制阻塞运行,新的io_uring 使用了线程池,它牛B的地方是可以让线程强轮询IO操作是否完成,而不使用硬件中断唤醒线程的方式,以减少唤醒线程所需要的额外延时。

微软的文章还提到了几个让异步操作变同步运行的情形,比如数据压缩加密等等。总而言之,异步IO操作在现如今阶段还有很大的局限性,Linux 新的异步IO特性 io_uring 已经用了线程池,恐怕也不会有什么大的改观。在没有硬件支持的情况下,哪有什么黑魔法呢?

注:本文大体基于官方描述,也有部分是笔者的猜想,比如缓冲IO操作也许远非笔者想象的那么简单。如果有错欢迎提出。

查看原文

赞 0 收藏 0 评论 0

CarterLi 发布了文章 · 2019-06-02

用 io_uring 替代 epoll 实现高速 polling

前面的文章说到 io_uring 是 Linux 中最新的原生异步 I/O 实现,实际上 io_uring 也支持 polling,是良好的 epoll 替代品。

API

使用 io_uring 来 poll 一个 fd 很简单。首先初始化 io_uring 对象(io_uring_queue_init),拿到 sqe(io_uring_get_sqe)是所有 io_uring 操作都必要的,前文已经介绍这里不做过多说明。拿到 sqe 之后,使用 io_uring_prep_poll_add 初始化 sqe 指针。

static inline void io_uring_prep_poll_add(struct io_uring_sqe *sqe, int fd,
                      short poll_mask);

第一个参数就是前面获得的 sqe 指针;第二个参数是你要 poll 的文件描述符;第三个是标志位,这里 io_uring 没有引入新的标志(宏),而是沿用了 poll(2) 定义的标志,如 POLLIN、POLLOUT 等。

如其他 I/O 请求一样,每个 sqe 都可以设置一个用户自己的值在里面,使用 io_uring_sqe_set_data

可以看到一次只能添加一个 poll 请求。如果有多个 fd,那么重复调用 io_uring_get_sqe 获取多个 sqe 指针分别 io_uring_prep_poll_add 即可。io_uring_get_sqe 不是系统调用不会进入内核,io_uring_prep_poll_add 则是简单的结构体参数赋值,所以没有速度问题。

添加完需要的请求后使用 io_uring_submit 统一提交、使用 io_uring_peek_cqe 获取完成情况等操作与标准异步 I/O 请求一致。

使用 io_uring 做 polling 与 epoll、poll 的默认模式有一个很大的区别就是 io_uring 的 polling 始终工作在 one-shot 模式下(等同于 epoll 的 EPOLLONESHOT),即一旦某个 poll 操作完成,用户必须重新提交 poll 请求否则不会触发新的事件,这样保证每个 poll 请求有且只有一个响应。然后既然是 one-shot 模式,也就没有类似 epoll 中的 LT、ET 模式之分

清除进行中的 polling 请求使用 io_uring_prep_poll_remove

static inline void io_uring_prep_poll_remove(struct io_uring_sqe *sqe,
                         void *user_data);

也是需要 sqe 然后 submit。可以看到这个函数很特别的直接需要 user_data 参数。内核是在用之前提交的 user_data 和你现在指定的 user_data 做对比,删除值相等的请求。

示例

在网络编程中最开始的需求就是异步监听客户端接入(O_NONBLOCK accept),这也是好多 epoll 的代码示例。用 io_uring 如下:

int sockfd = socket(...);
bind(...);
listen(...);

struct io_uring ring;
io_uring_queue_init(32, &ring, 0);

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_poll_add(sqe, sockfd, POLLIN);
io_uring_submit(&ring);

struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);

int clientfd = accept(sockfd, ...);

个人感觉如果拿 io_uring 纯做 polling 的话没有什么优势。拿 io_uring 做 polling 最有用的一点是把 polling 和 aio 的完成事件做统一监听和处理。想象拿到 clientfd 之后就可以立即使用 io_uring_prep_readv 读取请求体,同时又可以再使用 io_uring_prep_poll_add 接受其他客户端接入,这样才是真正的异步编程。

查看原文

赞 3 收藏 1 评论 0

CarterLi 发布了文章 · 2019-05-27

原生的 Linux 异步文件操作,io_uring 尝鲜体验

Linux异步IO的历史

异步IO一直是 Linux 系统的痛。Linux 很早就有 POSIX AIO 这套异步IO实现,但它是在用户空间自己开用户线程模拟的,效率极其低下。后来在 Linux 2.6 引入了真正的内核级别支持的异步IO实现(Linux aio),但是它只支持 Direct IO,只支持磁盘文件读写,而且对文件大小还有限制,总之各种麻烦。到目前为止(2019年5月),libuv 还是在用pthread+preadv的形式实现异步IO。

随着 Linux 5.1 的发布,Linux 终于有了自己好用的异步IO实现,并且支持大多数文件类型(磁盘文件、socket,管道等),这个就是本文的主角:io_uring

IOCP

于IO多路复用模型 epoll 不同,io_uring 的思想更类似于 Windows 上的 IOCP。用快递来举例:同步模型就是你从在电商平台下单前,就在你家楼下一直等,直到快递公司把货送到楼下,你再把东西带上楼。epoll 类似于你下单,快递公司送到楼下,通知你可以去楼下取货了,这时你下楼把东西带上来。虽然还是需要用户下楼取货(有一段同步读写的时间),但是由于不需要等快递在路上的时间,效率已经有非常大的提升。但是,epoll不适用于磁盘IO,因为磁盘文件总是可读的。

而 IOCP 就是一步到位,直接送货上门,连下楼取的动作都不需要。整个过程完全是非阻塞的。

io_uring 的简单使用

io_uring 是一套系统调用接口,虽然总共就3个系统调用,但实际使用却非常复杂。这里直接介绍封装过便于用户使用的 liburing

在尝试前请首先确认自己的 Linux 内核版本在 5.1 以上(uname -r)。liburing 需要自己编译(之后可能会被各大Linux发行版以软件包的形式收录),git clone 后直接 ./configure && sudo make install 就好了。

io_uring 结构初始化

liburing 提供了自己的核心结构 io_uring,它内部封装了 io_uring 自己的文件描述符(fd)以及其他与内核通信所需变量。

struct io_uring {
    struct io_uring_sq sq;
    struct io_uring_cq cq;
    int ring_fd;
};

使用之前需要先初始化,使用 io_uring_queue_init 初始化此结构。

extern int io_uring_queue_init(unsigned entries, struct io_uring *ring,
    unsigned flags);

如函数名称所示, io_uring 是一个循环队列(ring_buffer)。第一个参数 entries 表示队列大小(实际空间可能比用户指定的大);第二个参数 ring 就是需要初始化的 io_uring 结构指针;第三个参数 flags 是标志参数,无特殊需要传 0 即可。例如

#include <liburing.h>
struct io_uring ring;
io_uring_queue_init(32, &ring, 0);

提交读、写请求

首先使用 io_uring_get_sqe 获取 sqe 结构。

extern struct io_uring_sqe *io_uring_get_sqe(struct io_uring *ring);

一个 sqe(submission queue entry)代表一次 IO 请求,占用循环队列一个空位。io_uring 队列满时 io_uring_get_sqe 会返回 NULL,注意错误处理。注意这里的队列是指未提交的请求,已提交的(但未完成)请求不占位置。

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);

然后使用 io_uring_prep_readvio_uring_prep_writev 初始化 sqe 结构。

static inline void io_uring_prep_readv(struct io_uring_sqe *sqe, int fd,
                       const struct iovec *iovecs,
                       unsigned nr_vecs, off_t offset);
static inline void io_uring_prep_writev(struct io_uring_sqe *sqe, int fd,
                    const struct iovec *iovecs,
                    unsigned nr_vecs, off_t offset);

第一个参数 sqe 即前面获取的 sqe 结构指针;fd 为需要读写的文件描述符,可以是磁盘文件也可以是socket;iovecs 为 iovec 数组,具体使用请参照 readv 和 writevnr_vecs 为 iovecs 数组元素个数,offset 为文件操作的偏移量。

可以看到这两个函数完全按照 preadvpwritev 设计,语义也相同,所以很好上手。需要注意的是,如果需要顺序读写文件,偏移量 offset 需要程序自己维护。

struct iovec iov = {
    .iov_base = "Hello world",
    .iov_len = strlen("Hello world"),
};
io_uring_prep_writev(sqe, fd, &iov, 1, 0);

初始化 sqe 后,可以用 io_uring_sqe_set_data,传入你自己的数据,一般是一个 malloc 得到的指针,C++ 里面可以直接传 this。

static inline void io_uring_sqe_set_data(struct io_uring_sqe *sqe, void *data);

注意 prep_* 中会 memset(0),所以一定要先 prep_*set_data。笔者这里纠结了两个小时。

准备好 sqe 后即可使用 io_uring_submit 提交请求。

extern int io_uring_submit(struct io_uring *ring);

你可以初始化多个 sqe 然后一次性 submit

io_uring_submit(&ring);

完成 IO 请求

io_uring_submit 都是异步操作,不会阻塞当前线程。那么如何得知提交的操作何时完成呢?liburing 提供了函数 io_uring_peek_cqeio_uring_wait_cqe 两个函数获取当前已完成的 IO 操作。

extern int io_uring_peek_cqe(struct io_uring *ring,
    struct io_uring_cqe **cqe_ptr);
extern int io_uring_wait_cqe(struct io_uring *ring,
    struct io_uring_cqe **cqe_ptr);

第一个参数是 io_uring 结构指针;第二个参数 cqe_ptr 是输出参数,是 cqe 指针变量的地址。

cqe(completion queue entry)标记一个已完成的 IO 操作,同时也记录的之前传入的用户数据。每个 cqe 都与前面的 sqe 对应。

这两个函数,io_uring_peek_cqe 如果没有已完成的 IO 操作时,也会立即返回,cqe_ptr 被置空;而
io_uring_wait_cqe 会阻塞线程,等待 IO 操作完成。

for (;;) {
    io_uring_peek_cqe(&ring, &cqe);
    if (!cqe) {
        puts("Waiting...");
        // accept 新连接,做其他事
    } else {
        puts("Finished.");
        break;
    }
}

上文简单起见用忙等待做示例,在实际应用场景中应该是一个事件循环,浏览器、nodejs 给我们内部隐藏了事件循环的实现,而写 C/C++ 语言只能我们自己做。

可通过 io_uring_cqe_get_data 获取前面给 sqe 设置的用户数据。

static inline void *io_uring_cqe_get_data(struct io_uring_cqe *cqe);

默认情况下 IO 完成事件不会从队列中清除,导致 io_uring_peek_cqe 会获取到相同事件,使用 io_uring_cqe_seen 标记该事件已被处理

static inline void io_uring_cqe_seen(struct io_uring *ring,
                     struct io_uring_cqe *cqe);
io_uring_cqe_seen(&ring, cqe);

清除 io_uring,释放资源

清除 io_uring 结构使用 io_uring_queue_exit

extern void io_uring_queue_exit(struct io_uring *ring);
io_uring_queue_exit(&ring);

完整代码列举如下:这段代码作用就是创建文件 /home/carter/test.txt 并写入字符串 Hello world

#include <liburing.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>

int main()
{
    struct io_uring ring;
    io_uring_queue_init(32, &ring, 0);

    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    int fd = open("/home/carter/test.txt", O_WRONLY | O_CREAT);
    struct iovec iov = {
        .iov_base = "Hello world",
        .iov_len = strlen("Hello world"),
    };
    io_uring_prep_writev(sqe, fd, &iov, 1, 0);
    io_uring_submit(&ring);

    struct io_uring_cqe *cqe;

    for (;;) {
        io_uring_peek_cqe(&ring, &cqe);
        if (!cqe) {
            puts("Waiting...");
            // accept 新连接,做其他事
        } else {
            puts("Finished.");
            break;
        }
    }
    io_uring_cqe_seen(&ring, cqe);
    io_uring_queue_exit(&ring);
}

可以看到,C语言的异步操作还是比同步操作复杂不少,libuv(nodejs 的底层 IO 库)已经 表示会引入 io_uring。如果要自己用,一定要使用一个协程库简化异步操作。

这里 是我使用自己编写的协程库 Cxx-yield 实现的一个简单的文件服务器 demo。可以看到,经过简单封装后,异步文件读写可以简化到一行:https://github.com/CarterLi/C...。就是那种在 JavaScript 里写 async、await 的快感

查看原文

赞 5 收藏 1 评论 3

CarterLi 发布了文章 · 2019-05-16

C++:string_view 与 C API 的互操作性

std::string_view 是 C++17 新加的一个类,是字符串的无所有权引用。对 std::string_view 的操作都不会生成新字符串(比如 substr),而是返回一个新的 string_view 但是引用原字符串。

这样效率是有提升,但是带来一个问题就是:std::string_view 没有 c_str 方法。

string_view 所引用的字符串很有可能不是 \0 结尾的(Null-terminated byte strings),大多数 C API 都要求零结尾字符串,导致 string_view 和 C 函数互操作性很差(请不要不加考虑直接把 string_view#data() 传给 C 函数)。

某些 C 函数接受长度参数,比如 fwrite,还有某些函数虽然没有显式的要求你传字符串长度,但是也可以指定,比如 printf。

大家都知道 printf 输出字符串的方式是 %s(请不要把字符串当格式化自字符串直接输出),其实 %s 还能接受一些参数,完整形式是:%.Ns。其中 N 代表字符串中输出字符的个数,截取前三个字符输出就是 %.3s。更强大的是 N 可以是字符 *,代表输出的字符通过参数传入,所以通过 printf 家族输出 string_view 的方式就是

printf("%.*s", int(sv.length()), sv.data());
查看原文

赞 1 收藏 0 评论 1

CarterLi 发布了文章 · 2019-04-22

生成器与迭代器

之前的文章 写到了 Generator 与异步编程的关系,其实简化异步编程只是 Generator 的“副业”,Generator 本身却不是为异步编程而存在。

生成器函数

我们看 Generator 自身的含义——生成器,就是产生序列用的。比如有如下函数:

function* range(start, stop) {
  for (let item = start; item < stop; ++item) {
    yield item;
  }
}

range 就是一个生成器函数,它自身是函数可以调用(typeof range === 'function' // true),但又与普通函数不同,生成器函数(GeneratorFunction)永远返回一个生成器(Generator

注:我们通常所说的 Generator 实际上指生成器函数(GeneratorFunction),而把生成器函数返回的对象称作迭代器(Iterator)。由于感觉“生成器函数”返回“生成器”这句话有些拗口,下文沿用生成器和迭代器的说法。

迭代器

初次调用生成器实际上不执行生成器函数的函数体,它只是返回一个迭代器,当用户调用迭代器的 next 函数时,程序才开始真正执行生成器的函数体。当程序运行到 yield 表达式时,会将 yield 后面表达式的值作为 next 函数的返回值(的一部分)返回,函数本身暂停执行。

const iterator = range(0, 10); // 获取迭代器
const value1 = iterator.next().value; // 获取第一个值 => 0
const value2 = iterator.next().value; // 获取第二个值 => 1

next 返回值是一个对象,包含两个属性 valuedone。value 即为 yield 后面表达式的值,done 表示函数是否已经结束(return)。如果函数 return(或执行到函数尾退出,相当于 return undefined),则 done 为 true,value 为 return 的值。

for...of 是遍历整个迭代器的简单方式。

生成器的用处

上面说到,生成器就是生成序列用的。但是与直接返回数组不同,生成器返回序列是一项一项计算并返回的,而返回数组总是需要计算出所有值后统一返回。所以至少有三种情况应该考虑使用生成器。

  • 序列有无限多项,或者调用者不确定需要多少项

range(0, Infinity) 是允许的,因为生成器没生成一个值就会暂停执行,所以不会造成死循环,可以由调用者选择何时停止。

注意此时不能使用 for...of,因为迭代器永远不会 done

  • 计算每一项耗时较长

如果计算一项的值需要 1ms,那么计算 1000 项就需要 1s,如果不将这 1s 拆分,就会导致浏览器卡顿甚至假死。这时可以用生成器每生成几项就将控制权交还给浏览器,用于响应用户事件,提升用户体验(当然这里更有效的方法是将代码放到 Web Worker 里执行)

  • 节省内存

如果序列很长,直接返回数组会占用较大内存。生成器返回值是一项一项返回,不会一次性占用大量内存(当然生成器为了保留执行上下文比通常函数占用内存更多,但是它是个定值,不随迭代次数增加)

使用生成器实现懒加载的 map、filter

Array#mapArray#filter 是 ES5 引入的(绝对不算新的)两个非常常用的函数,前者将数组每一项通过回调函数映射到新数组(值变量不变),后者通过回调函数过滤某些不需要的项(量变值不变),他们都会生成新的数组对象,如果数组本身较长或者写了很长的 map、filter 调用链,就可能造成内存浪费。

这时就可以考虑使用生成器实现这两个函数,非常简单:

function* map(iterable, callback) {
  let i = 0;
  for (const item of iterable) {         // 遍历数组
    yield callback(item, i++, iterable); // 获取其中一项,调用回调函数,yield 返回值
  }
}

function* filter(iterable, callback) {
  let i = 0;
  for (const item of iterable) {         // 遍历数组
    if (callback(item, i++, iterable)) { // 获取其中一项,调用回调函数
      yield item;                        // 仅当回调函数返回真时,才 yield 值
    }
  }
}

可以看到我在代码中写的是“可迭代的”(iterable),而不限于数组(由于实现了 Symbol.iterator 所以数组也是可迭代对象)。比如可以这么用:

const result = map(     // (1)
  filter(               // (2)
    range(1, 10000),    // (3)
    x => x % 2 === 0,
  ),
  x => x / 2,
)
console.log(...result); // (4)

注意,程序在解构运算符 ...result 这一步才真正开始计算 result 的值(所谓的懒加载),而且它的值也是一个一个计算的:

  1. (3)生成迭代器,提供值给(2);(2)提供值给(1)
  2. (1)中的result也是迭代器,在这一步所有函数都没有真正开始执行,因为没有任何代码问他们索要值。
  3. (4)中的扩展运算符对迭代器 result 进行了求值,生成器真正开始执行。
  4. result 的值来自于 (1),于是(1)首先开始执行。
  5. (1)中map函数使用 for...of 遍历(2)提供的迭代器,于是(2)开始执行
  6. (2)中filter函数使用 for...of 遍历(3)提供的迭代器,于是(3)开始执行
  7. (3)中range函数开始执行,循环得到第一个值 1。遇到 yield 关键字,将值 1 输出给(2)
  8. (2)中的 for...of 获得一个值 1,执行函数体。callback 返回 false,忽略之。回到 for...of,继续问(3)索要下一个值
  9. (3)range 输出第二个值 2
  10. (2)中的 for...of 获得一个值 2,执行函数体。callback 返回 true,将值 2 输出给 (1)
  11. (1)中的 for...of 获得一个值 2,执行函数体得到 1。将值 1 输出给(4),console.log 获得第一个参数
  12. (4)检测result还没有结束(done为false),问result索要下一个值。
  13. 回到第 4 步循环,直至(3)中的循环结束函数体退出为止,(3)返回的迭代器被关闭
  14. (2)中 for...of 检测到迭代器已被关闭(done为true),循环结束,函数退出,(2)返回的迭代器被关闭
  15. 同理(1)返回的迭代器被关闭
  16. (4)中解构运算符检测到result已关闭,结构结束。将结构得到的所有值作为 console.log 的参数列表输出

总结一下,代码执行顺序大概是这样:(3) -> (2) -> (1) -> (4) -> (1) -> (2) -> (3) -> (2) -> (3) -> (2) -> (1) -> (4) -> (1) -> (2) -> (3) -> ……

是不是相当复杂?异步函数中“跳来跳去”的执行顺序也就是这么来的。跟递归函数一样,不要太纠结生成器函数的执行顺序,而要着重理解它这一步究竟要做什么事情。

生成器函数的链式调用

这样的代码 map(filter(range(1, 100), x => x % 2 === 0), x => x / 2) 似乎有些d疼,好像又看到了被回调函数支配的恐惧。虽然有人提出了管道符的提议,但这种 stage 1 的提议被标准接受直至有浏览器实现实在是遥遥无期,有没有其他办法呢?

普通函数可以简单的通过在类中返回 this 实现函数的链式调用(例如经典的 jQuery),但是这点在生成器中不适用。我们前面说过生成器函数本身永远返回一个迭代器,而生成器中的 return 语句实际上是关闭迭代器的标志,return this 实际代表 { value: this, done: true }。生成器中的 return 和普通函数用法相近但实际含义大大不同。

链式调用需要函数的返回值是个对象,并且对象中包含可链式调用的所有函数。生成器函数返回的迭代器本身就是一个对象,很容易想到改变对象的原型实现。

迭代器有如下原型继承链:

迭代器对象 -> 生成器.prototype -> 生成器.prototype.prototype(Generator) -> Object.prototype -> null

clipboard.png

可以看到,生成器返回的迭代器对象就好像是被生成器 new 出来的一样(但是生成器不是构造函数不能被 new)。但是总之我们可以通过给生成器函数的 prototype 添加方法实现给迭代器添加方法的效果。实现如下

function* range(start, stop) {
  for (let item = start; item < stop; ++item) {
    yield item;
  }
}

function* map(callback) {
  let i = 0;
  for (const item of this) {
    yield callback(item, i++, this);
  }
}

function* filter(callback) {
  let i = 0;
  for (const item of this) {
    if (callback(item, i++, this)) {
      yield item;
    }
  }
}

[range, map, filter].forEach(x => Object.assign(x.prototype, { range, map, filter }));

// 使用
const result = range(1, 100)
  .filter(x => x % 2 === 0)
  .map(x => x / 2);

console.log(...result);

笔者业(xian)余(de)时(dan)间(teng)使用迭代器实现了几乎所有 ES7 中 Array 的成员方法和静态方法,广而告之,欢迎来喷:https://github.com/CarterLi/q...

查看原文

赞 0 收藏 0 评论 0

CarterLi 关注了专栏 · 2019-02-25

前端开发

前端开发

关注 1140

CarterLi 关注了专栏 · 2019-02-25

前端路漫漫

路漫漫其修远兮 吾将上下左右东西南北中发白而求索

关注 1226

认证与成就

  • 获得 272 次点赞
  • 获得 4 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 4 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-07-31
个人主页被 1.6k 人浏览