按照 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