按照 andrew clark 在 reactpodcast.com/70 所述,react hooks 即便说不上是百分百为了 concurrent mode 设计,也绝大部分是为了 concurrent mode 设计,这样保证一旦 concurrent mode 落地,你们用 react hooks 编写的代码都已是并发安全的了,至于其余的逻辑复用等特性都只是反作用而已,在脱离并发语境下是难以理解 react hooks 的设计的,本文主要讨论下一下 react 的并发设计。前端
Javascript 虽然是单线程语言,可是其仍然能够进行并发,好比 node.js 里平常使用的各类异步 api,都能帮咱们编写并发的代码。以下面一个简单的 http echo 服务器就支持多个请求并发处理node
const net = require("net");
const server = net.createServer(function(socket) {
socket.on("data", function(data) {
socket.write(data);
});
socket.on("end", function() {
socket.end();
});
});
server.listen(8124, function() {
console.log("server bound");
});
复制代码
除了宿主环境提供的异步 IO,Javascript 还提供了一个另外一个常被忽略的并发原语: 协程 (Coroutine)python
在讲协程以前简单的回顾一下各类上下文切换技术,简单定义一下上下文相关的术语react
那么咱们有哪些上下文切换的方式呢linux
进程是最传统的上下文系统,每一个进程都有独立的地址空间和资源句柄,每次新建进程时都须要分配新的地址空间和资源句柄(能够经过写时赋值进行节省),其好处是进程间相互隔离,一个进程 crash 一般不会影响另外一个进程,坏处是开销太大webpack
进程主要分为三个状态: 就绪态、运行态、睡眠态,就绪和运行状态切换就是经过调度来实现,就绪态获取时间片则切换到运行态,运行态时间片到期或者主动让出时间片 (sched_yield) 就会切换到就绪态,当运行态等待某系条件(典型的就是 IO 或者锁)就会陷入睡眠态,条件达成就切换到就绪态。nginx
线程是一种轻量级别的进程(linux 里甚至不区分进程和线程),和进程的区别主要在于,线程不会建立新的地址空间和资源描述符表,这样带来的好处就是开销明显减少,可是坏处就是由于公用了地址空间,可能会形成一个线程会污染另外一个线程的地址空间,即一个线程 crash 掉,极可能形成同一进程下其余线程也 crash 掉c++
www.youtube.com/watch?v=cN_… 如 rob pike 演讲所述,并发并不等于并行,并行须要多核的支持,并发却不须要。线程和进程即支持并发也支持并行。
并行强调的是充分发挥多核的计算优点,而并发更增强调的是任务间的协做,如 webpack 里的 uglify 操做是明显的 CPU 密集任务,在多核场景下使用并行有巨大的优点,而 n 个不一样的生产者和 n 个不一样消费者之间的协做,更强调的是并发。实际上咱们绝大部分都是把线程和进程当作并发原语而非并行原语使用。git
在 Python 没引入 asycio 支持前,绝大部分 python 应用编写网络应用都是使用多线程 | 多进程模型, 如考察下面简单的 echo server 实现。github
import socket
from _thread import *
import threading
def threaded(c):
while True:
data = c.recv(1024)
if not data:
print('Bye')
break
c.send(data)
c.close()
def Main():
host = ""
port = 12345
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind((host, port))
print("socket binded to port", port)
s.listen(5)
print("socket is listening")
# a forever loop until client wants to exit
while True:
c, addr = s.accept()
print('Connected to :', addr[0], ':', addr[1])
start_new_thread(threaded, (c,))
s.close()
if __name__ == '__main__':
Main()
复制代码
咱们发现咱们这里虽然使用了多线程,可是这里的多线程更多的是用于并发而非并行,其实咱们的任务绝大部分时间都是耗在了 IO 等待上面,这时候你是单核仍是多核对系统的吞吐率影响其实不大。
因为多进程内存开销较大,在 C10k 的时候,其建立和关闭的内存开销已基本不可接受,而多线程虽然内存开销较多进程小了很多,可是却存在另外一个性能瓶颈:调度
linux 在使用 CFS 调度器的状况下,其调度开销大约为 O(logm), 其中 m 为活跃上下文数,其大约等同于活跃的客户端数,所以每次线程遇到 IO 阻塞时,都会进行调度从而产生 O(logm) 的开销。这在 QPS 较大的状况下是一笔不小的开销。
咱们发现上面多线程网络模型的开销是由两个缘由致使的:
若是想要突破 C10k 问题,咱们就须要下降调度频率和减少调度开销。咱们进一步发现这两个缘由甚至是紧密关联的
因为使用了阻塞 IO 进行读写 socket,这致使了咱们一个线程只能同时阻塞在一个 IO 上,这致使了咱们只能为每一个 socket 分配一个线程。即阻塞 IO 即致使了咱们调度频繁也致使了咱们建立了过多的上下文。
因此咱们考虑使用非阻塞 IO 去读写 socket。
一旦使用了非阻塞 IO 去读写 socket,就面临读 socket 的时候,没就绪该如何处理,最粗暴的方式固然是暴力重试,事实上 socket 大部分时间都是属于未就绪状态,这实际上形成了巨大的 cpu 浪费。
这时候就有其余两种方式就绪事件通知和异步 IO,linux 下的主流方案就是就绪事件通知,咱们能够经过一个特殊的句柄来通知咱们咱们关心的 socket 是否就绪,咱们只要将咱们关心的 socket 事件注册在这个特殊句柄上,而后咱们就能够经过轮训这个句柄来获取咱们关心的 socket 是否就绪的信息了,这个方式区别于暴力重试 socket 句柄的方式在于,对 socket 直接进行重试,当 socket 未就绪的时候,因为是非阻塞的,会直接进入下次循环,这样一直循环下去浪费 cpu,可是对特殊句柄进行重试,若是句柄上注册是事件没有就绪,该句柄自己是会阻塞的,这样就不会浪费 cpu 了,在 linux 上这个特殊句柄就是大名鼎鼎的 epoll。使用 epoll 的好处是一方面因为避免直接使用阻塞 IO 对 socket 进行读写,下降了触发调度的频率,如今的上下文切换并非在不一样线程之间进行上下文切换,而是在不一样的事件回调里进行上下文切换,这时的 epoll 处理事件回调上下文切换的复杂度是 O(1) 的,因此这大大提升了调度效率。可是 epoll 在处理上下文的注册和删除时的复杂度是 O(logn), 但对于大部分应用都是读写事件远大于注册事件的,固然对于那些超短连接,可能带来的开销也不小。
咱们发现使用 epoll 进行开发 server 编程的风格以下
import socket;
import select;
#开启一个Socket
HOST = '';
PORT = 1987
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind((HOST, 1987));
sock.listen(1);
#初始化Epoll
epoll = select.epoll();
epoll.register(sock.fileno(), select.EPOLLIN);
#链接和接受数据
conns = {};
recvs = {};
try:
while True:
#等待事件发生
events = epoll.poll(1);
#事件循环
for fd, event in events:
#若是监听的Socket有时间则接受新链接
if fd == sock.fileno():
client, addr = sock.accept();
client.setblocking(0);
#注册新链接的输入时间
epoll.register(client.fileno(), select.EPOLLIN);
conns[client.fileno()] = client;
recvs[client.fileno()] = '';
elif event & select.EPOLLIN:
#读取数据
while True:
try:
buff = conns[fd].recv(1024);
if len(buff) == 0:
break;
except:
break;
recvs[fd] += buff;
#调整输出事件
if len(buff) != 0:
epoll.modify(fd, select.EPOLLOUT);
else:
#若是数据为空则链接已断开
epoll.modify(fd, select.EPOLLHUP);
elif event & select.EPOLLOUT:
#发送数据
try:
n = conns[fd].send(recvs[fd]);
recvs[fd] = '';
#从新调整为接收数据
epoll.modify(fd, select.EPOLLIN);
except:
epoll.modify(fd, select.EPOLLHUP);
elif event & select.EPOLLHUP:
#关闭清理链接
epoll.unregister(fd);
conns[fd].close();
del conns[fd];
del recvs[fd];
finally:
epoll.unregister(sock.fileno());
epoll.close();
sock.close();
复制代码
咱们发现实际上咱们的业务逻辑被拆分为一系列的事件处理,并且咱们发现绝大部分的网络服务基本都是这种模式,
那是否是能够进一步的将这种模式进行封装。epoll 其实还存在一些细节问题,如并不能直接用于普通文件,这致使使用 epoll 方案时,一旦去读写文件仍然会陷入阻塞,所以咱们须要对文件读写进行特殊处理(pipe + 线程池),对于其余的异步事件如定时器,信号等也没办法经过 epoll 直接进行处理,都须要自行封装。
咱们发现直接使用 epoll 进行编程时仍是会须要处理大量的细节问题,并且这些细节问题几乎都是和业务无关的,咱们其实不太关心内部是怎么注册 socket 事件 | 文件事件 | 定时器事件等,咱们关心的其实就是一系列的事件。因此咱们能够进一步的将 epoll 进行封装,只给用户提供一些事件的注册和回调触发便可。这其实就是 libuv 或者更进一步 nodejs 干的事情。
咱们平常使用 nodejs 开发代码的风格是这样的
var net = require("net");
var client = net.connect({ port: 8124 }, function() {
//'connect' listener
console.log("client connected");
client.write("world!\r\n");
});
client.on("data", function(data) {
console.log(data.toString());
client.end();
});
client.on("end", function() {
console.log("client disconnected");
});
复制代码
此时使用事件驱动编程虽然极大的解决了服务器在 C10k 下的性能问题,可是却带来了另外的问题。
使用事件驱动编程时碰到的一个问题是,咱们的业务逻辑被拆散为一个个的 callback 上下文,且借助于闭包的性质,咱们能够方便的在各个 callback 之间传递状态,而后由 runtime(好比 node.js 或者 nginx 等)根据事件的触发来执行上下文切换。
咱们为何须要将业务拆散为多个回调,只提供一个函数不行吗?
问题在于每次回调的逻辑是不一致的,若是封装成一个函数,由于普通函数只有一个 entry point,因此这实际要求函数实现里须要维护一个状态机来记录所处回调的位置。固然能够这样去实现一个函数,可是这样这个函数的可读性会不好。
假如咱们的函数支持多个入口,这样就能够将上次回调的记过天然的保存在函数闭包里,从下个入口进入这个函数能够天然的经过闭包访问上次回调执行的状态,即咱们须要一个可唤醒可中断的对象,这个可唤醒可中断的对象就是 coroutine。
我没找到 coroutine 的精肯定义,并且不一样语言的 coroutine 实现也各有不一样,但基本上来讲 coroutine 具备以下两个重要性质
这里咱们能够将其和函数和线程对比
回忆一下咱们的 js 里是否有对象知足这两个性质呢,很明显由于 JS 是单线程的,因此不可抢占这个性质自然知足,咱们只须要考虑第一个性质便可,答案已经很明显了,Generator 和 Async/Await 就是 coroutine 的一种实现。
如此文所示 zhuanlan.zhihu.com/p/98745778, Generator 刚开始只是做为简化 Iterableiterator 的实现,后来渐渐的在此之上加上了 coroutine 的功能。
虽然 Javascript 里 Generator 对 coroutine 的支持是一步到位的,可是 Python 里 generator 对 coroutine 的支持倒是慢慢演进的,感兴趣的能够看看 Python 里的 Generator 是如何演变为 Coroutine 的 (www.python.org/dev/peps/pe…, www.python.org/dev/peps/pe…, www.python.org/dev/peps/pe… 等等)
咱们的 Generator 能够同时做为生产者和消费者使用
做为生产者的 generator
function* range(lo, hi) {
while (lo < hi) {
yield lo++;
}
}
console.log([...range(0, 5)]); // 输出 0,1,2,3,4,5
复制代码
做为消费者的 Generator
function* consumer() {
let count = 0;
try {
while (true) {
const x = yield;
count += x;
console.log("consume:", x);
}
} finally {
console.log("end sum:", count);
}
}
const co = consumer();
co.next();
for (const x of range(1, 5)) {
co.next(x);
}
co.return();
/*
输出结果
produce: 1
consume: 1
produce: 2
consume: 2
produce: 3
consume: 3
produce: 4
consume: 4
end sum: 10
*/
复制代码
若是熟悉 RXJS 的同窗,RXJS 里也有个对象能够同时做为生产者和消费者即 Subject,这实际上使得咱们能够将 Generator 进一步的做为管道或者 delegator 来使用,Generator 经过 yield * 更进一步的支持了该用法并且还能够在递归场景下使用。
以下咱们能够经过 yield from 支持将一个数组打平
function* flatten(arr) {
for (const x of arr) {
if (Array.isArray(x)) {
yield* flatten(x);
} else {
yield x;
}
}
}
console.log([...flatten([1, [2, 3], [[4, 5, 6]]])]);
复制代码
此作法相比于传统的递归实现,在于其能够处理无限深度的元素(传统递归在这里就挂掉了)
上面的 Generator 更多的在于将其当作一种支持多值返回的函数使用,然而假如咱们将每一个 generator 都当作一个 task 使用的话,将会发现更多威力。如笔者以前的文章里 zhuanlan.zhihu.com/p/24737272,能够用 generator 来进行 OS 的模拟, Generator 在离散事件仿真领域发挥了重大做用(本身用 generator 来实现个排序动画试试)。
generator 虽然具备上述功能,但仍是有个很大的局限。观察下述代码
function caller() {
const co = callee();
co.next();
co.next("step1");
co.next("step2");
}
function* callee() {
// do something
step1 = yield;
console.log("step1:", step1);
step2 = yield;
console.log("step2:", step2);
}
caller();
复制代码
我么发现虽然咱们的 callee 能够主动的让出时间片,可是下一个调度的对象并非随机选择的,下一个调度的对象必然是 caller,这是一个很大的局限,这里意味着 caller 能够决定任意 callee 的调度,可是 callee 却只能调度 caller,这里存在明显的不对称性,所以 Generator 也被称为非对称协程或者叫半协程(在 python 里叫 Simple Coroutine),虽然咱们能够经过 en.wikipedia.org/wiki/Trampo… 来本身封装一个 scheduler 来决定下一个任务(实际上 co 就是个 Trampoline 实现)实现任意任务的跳转,可是咱们仍是指望有个真正的协程。
上面讲到 Generator 的最大限制在于 coroutine 只能 yield 给 caller,这在实际应用中存在较大的局限,例如通常的调度器是根据优先级进行调度,这个优先级多是任务的触发顺序也有多是任务自己手动指定的优先级,考虑到大部分的 web|server 应用,绝大部分场景都是处理异步任务,因此若是能内置异步任务的自动调度,那么基本上能够知足大部分的需求。
const sleep = ms =>
new Promise(resolve => {
setTimeout(resolve, ms);
});
async function task1() {
while (true) {
await sleep(Math.random() * 1000);
console.log("task1");
}
}
async function task2() {
while (true) {
await sleep(Math.random() * 1000);
console.log("task2");
}
}
async function task3() {
while (true) {
await sleep(Math.random() * 1000);
console.log("task3");
}
}
function main() {
task1();
task2();
task3();
console.log("start task");
}
main();
复制代码
此时咱们发现咱们可以进行任意任务之间的跳转,如 task1 调度到 task2 后,而后 task2 又调度到 task3,此时的调度行为彻底由内置的调度器根据异步事件的触发顺序来决定的。虽然 async/await 异常方便,可是仍然存在诸多限制
事实上 React Fiber 是另外一种协程的实现方式,事实上 React 的 coroutine 的实现经历过几回变更
如 github.com/facebook/re…,fiber 大部分状况下和 coroutine 的功能相同均支持 cooperative multitasking,主要的区别在于 fiber 更多的是系统级别的,而 coroutine 则更多的是 userland 级别的,因为 React 并无直接暴露操做 suspend 和 resume 的操做,更多的是在框架级别进行 coroutine 的调度,所以叫 fiber 可能更为合理(但估计更合理的名字来源于 ocaml 的 algebraic effect 是经过 fiber 实现的)。
React 之因此没有直接利用 js 提供的 coroutine 原语即 async|await 和 generator,其主要缘由在于
没使用 Async|await 的缘由也与此相似,为了更加细粒度的进行任务调度,react 经过 fiber 实现了本身协程。
react 经过 fiber 迈入了并发的世界,然而并发世界充满了各类陷阱,接触过多线程编程的同窗可能都知道编写一个线程安全的函数是多么困难 (试着用 c++ 写一个线程安全的单例试试),那么 react 为何非要进入这个泥淖呢。
很幸运的是,因为 Javascript 是单线程的,咱们自然的避免了多线程并行的各类 edge case,实际上咱们只须要处理好并发安全便可。
在多线程环境下,任意的共享变量的修改,都须要使用锁去保护,不然就不是线程安全的。
而下述代码始终是线程安全的
class Count {
count = 0;
add() {
this.count++;
}
}
复制代码
然而单线程并非万能灵药,即便咱们摆脱了并行可抢占带来的问题,可是可重入的问题,仍然须要咱们解决。
可重入性是指一个函数能够安全的支持并发调用,在单线程的 Javascript 里彷佛并不存在同时调用一个函数的情形,实际并不是如此,最最多见的递归就是一个函数被并发调用, 如上面提到的 flatten 函数 (即便非递归也可能存在可重入)
function* flatten(arr) {
for (const x of arr) {
if (Array.isArray(x)) {
yield* flatten(x);
} else {
yield x;
}
}
}
console.log([...flatten([1, [2, 3], [[4, 5, 6]]])]);
复制代码
例如咱们传入 arr = [[1]],其调用链以下
flatten([[[1]]])
// flatten start
=> flatten([[1]])
=> flatten([1]) // 这里实际同时存在了三个flatten的调用
复制代码
一个常见的非可重入安全函数以下
const state = {
name: "yj"
};
function test() {
console.log("state:", state.name.toUpperCase());
state.name = null;
}
test();
test(); // crash
复制代码
咱们发现第二次的调用是因为第一次调用偷偷修改了 state 的致使,而 test 先后两次调用共享了外部的 state,你们确定回想,通常确定不会犯这个错误,因而将代码修改以下
const state = {
name: "yj"
};
function test(props) {
console.log("state:", state.name.toUpperCase());
state.name = null;
}
test(state);
test(state); // state
复制代码
虽然此时咱们摆脱了全局变量,可是因为先后两个 props 实际上指向的仍然是同一个对象,咱们的代码仍然 crash 掉,实际上不只仅是 crash 是个问题,下述代码在某些场景下依然存在问题
function* app() {
const btn1 = Button();
yield; // 插入点
const btn2 = Button();
yield [btn1, btn2];
}
function* app2() {
yield Alert();
}
let state = {
color: "red"
};
function useRef(init) {
return {
current: init
};
}
function Button() {
const stateRef = useRef(state);
return stateRef.current.color;
}
function Alert() {
const stateRef = useRef(state);
stateRef.current.color = "blue";
return stateRef.current.color;
}
function main() {
const co = app();
const co2 = app2();
co.next();
co2.next();
co2.next();
console.log(co.next());
}
main();
// 输出结果
{ value: [ 'red', 'blue' ], done: false }
复制代码
此时咱们发现咱们的打印结果,虽然使用了同一个 button,可是结果是不一致的,这基本上能够对应以下的 React 代码
function App2(){
return (
<>
<Button />
<Yield /> // 至关于插入了个yield
<Button />
</>
)
}
function App2(){
return (
<Alert />
)
}
function Button(){
const state = useContext(stateContext);
return state.color
}
function Alert(){
const state = useContext(stateContext);
state.color = 'blue';
return state.color;
}
function App(){
return (
<App1/>
<Yield/> // 至关于插入了个yield
<App2/>
);
}
ReactDOM.render(
<StateProvider value={store}>
<App/>
</StateProvider>);
复制代码
在 ConcurrentMode 下,至关于每一个相邻的 Fiber node 之间都插入了 yield 语句,这使得咱们的组件必需要保证组件是重入安全的,不然就可能形成页面 UI 的不一致,更严重的会形成页面 crash,这里出现问题的主要缘由在于
因此 React 官方要求用户在 render 期间禁止作任何反作用。
todo
插播广告:字节跳动上海前端团队目前依然有大量岗位开放中,校招、社招、实习都可,业务方向涉及国内 / 海外,业务类型有社区、社交、在线教育、Infra 等等,欢迎勾搭,邮箱地址:yangjian.fe@bytedance.com,详情请参考:zhuanlan.zhihu.com/p/86442059