According to Andrew Clark at reactpodcast.com/70, React hooks are mostly, if not 100%, designed for concurrent mode, This ensures that once a concurrent mode is implemented, code written with React hooks is concurrency safe. Other features such as logic reuse are side effects. This article focuses on concurrency design for React.

Javascript is a single-threaded language, but it is still capable of concurrency. For example, the various asynchronous apis used in Node.js help us write concurrent code. A simple HTTP Echo server, such as the one below, supports concurrent processing of multiple requests

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");
});

Copy the code

In addition to the asynchronous IO provided by the host environment, Javascript provides another often overlooked concurrency primitive: coroutines.

Context switch

Before we move on to coroutines, let’s briefly review the various context switching techniques and briefly define context-specific terms

  • Context: A state in which a program is running
  • Context switching: The technique of switching from one context to another
  • Scheduling: A method for determining which context gets a SLICE of CPU time

So what are the ways in which we can switch contexts

process

Process is the most traditional context system, each process has a separate address space and resources to handle, each new process need to assign a new handle to address space and resources (can be save through writing assignment), its benefit is mutual isolation between process, a process crash usually won’t affect another process, the downside is that spending is too big

Processes are divided into three main states: Ready-to-run, run-sleep, and run-run transitions are done through scheduling, with ready-to-run time slices being acquired, ready time slices being switched to run-time, running time slices being expired or sched_yield being voluntarily given. When the running state waits for a set of conditions (typically IO or lock), it falls asleep and switches to the ready state.

thread

Thread is a lightweight process (don’t even distinguish between processes and threads in Linux), and processes the difference mainly lies in the fact that the thread does not create new address space and resource descriptor table, this is the benefits of spending significantly reduced, but the downside is because public address space, may cause a thread will contaminate the address space of another thread. If a thread crashes, it may cause other threads in the same process to crash

Concurrency and Parallelism

www.youtube.com/watch?v=cN_… As Mentioned in Rob Pike’s talk, concurrency is not the same as parallelism. Parallelism requires multi-core support, whereas concurrency does not. Threads and processes support both concurrency and parallelism. Parallel emphasizes on giving full play to the computing advantages of multi-core, while concurrency emphasizes more on the collaboration between tasks. For example, Uglify operation in Webpack is obviously cpu-intensive task, and parallelism has huge advantages in multi-core scenarios, while the collaboration between N different producers and N different consumers. More emphasis is placed on concurrency. In fact, we mostly use threads and processes as concurrent primitives rather than parallel primitives.

A network model

Before Python didn’t introduce asycio support, the vast majority of Python application writing network applications are using multithreading | multi-process model, such as the following simple echo server implementation.

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()

Copy the code

We found that although we use multithreading here, but the multithreading here is more for concurrent rather than parallel, in fact, most of our task time is spent on IO waiting, at this time, whether you single-core or multi-check the system throughput rate is not very important. Due to the large memory overhead of multi-process, the memory overhead of creating and closing of multi-process is basically unacceptable at C10k. However, although multi-process has a large memory overhead, the process is much smaller, but there is another performance bottleneck: Scheduling Linux with the CFS scheduler has a scheduling overhead of O(logm), where M is the number of active contexts, which is approximately equal to the number of active clients. Therefore, each time a thread encounters AN I/O block, the scheduling overhead is O(logm). This is a significant cost in the case of large QPS.

Non-blocking IO and event-driven

We find that the overhead of the above multi-threaded network model is caused by two reasons:

  • I/O blocks read/write sockets, causing scheduling: Scheduling is frequent
  • A large number of active contexts results in high scheduling overhead and low scheduling efficiency

If we want to break through the C10k problem, we need to reduce the scheduling frequency and scheduling overhead. We further found that the two reasons were even closely related because of the use of blocking IO to read and write sockets, which resulted in a thread blocking on only one IO at a time, which resulted in only one thread per socket. Blocking IO caused us to schedule too much and created too many contexts. So we consider using non-blocking IO to read and write sockets. Once a socket is read and written using non-blocking IO, it is not ready to read the socket. The most violent way is of course to retry the socket by force. In fact, most of the time the socket is not ready, which actually causes a huge waste of CPU. There are two other ways to do this: ready event notification and asynchronous IO. The main solution under Linux is ready event notification. We can use a special handle to tell us whether the socket we care about is ready. We can then train the handle to get information about whether the socket we care about is ready. This method is different from the violent retry of the socket handle by directly retrying the socket. When the socket is not ready, it will go straight to the next loop because it is not blocking. This loop would waste CPU, but retry the special handle, which itself would block if the event was not ready to register on the handle, so CPU would not be wasted. On Linux this special handle is known as epoll. The advantage of using epoll is that, on the one hand, it avoids the direct use of blocking IO to read and write sockets and reduces the frequency of triggering scheduling. The context switch is not carried out between different threads, but in different event callbacks. At this time, epoll handles the event callback context switch with O(1) complexity, so this greatly improves scheduling efficiency. However, the complexity of epoll is O(logn) when dealing with the registration and deletion of context, but for most applications, read and write events are much more than registered events. Of course, for those ultra-short links, the overhead may be not small. We found the following style of development server programming using epoll

import socket; import select; # open a Socket HOST = ''; PORT = 1987 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.bind((HOST, 1987)); sock.listen(1); Epoll = select.epoll(); epoll.register(sock.fileno(), select.EPOLLIN); # connect and accept data conns = {}; recvs = {}; Event = epoll.poll(1); 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: # read data while True: try: buff = conns[fd].recv(1024); if len(buff) == 0: break; except: break; recvs[fd] += buff; # Adjust output event if len(buff)! = 0: epoll.modify(fd, select.EPOLLOUT); Epoll.modify (fd, select.epollhup); else: # poll.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: # close poll.unregister(fd); conns[fd].close(); del conns[fd]; del recvs[fd]; finally: epoll.unregister(sock.fileno()); epoll.close(); sock.close();Copy the code

We found that in fact our business logic is split into a series of event processing, and we found that most of the network services basically follow this pattern, so can we further encapsulate this pattern? Epoll actually has some details, for example, it cannot be used directly for ordinary files, which leads to the blocking of file reading and writing when using the epoll scheme. Therefore, we need special processing for file reading and writing (PIPE + thread pool). For other asynchronous events, such as timers, Signals cannot be processed directly through epoll, and they need to be packaged by themselves.

We found that the direct use of epoll programming will still need to deal with a lot of detail, and these details are almost has nothing to do with the business, we are not too concerned about what internal register socket | | file events timer events, etc., we care about is actually a series of events. So we can further encapsulate epoll to provide users with only some event registration and callback triggers. That’s what Libuv or even nodejs does. Our daily code development style with NodeJS looks like this

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"); });Copy the code

At this time, event-driven programming has greatly solved the performance problem of the server under C10k, but it has brought another problem.

Coroutines (coroutine)

One of the problems with event-driven programming is that our business logic is broken up into callback contexts, and with the nature of closures we can easily pass state between them. It is then up to the Runtime (such as Node.js or Nginx) to perform a context switch based on the event being triggered. Why do we need to split the business into multiple callbacks, not just one function? The problem is that the logic of each callback is inconsistent. If it is encapsulated as a function, since ordinary functions only have one entry point, this actually requires the function implementation to maintain a state machine to record the position of the callback. Of course you can implement a function this way, but the function will be very unreadable. Support multiple entry if our function, so that it can be the last time the callback the demerit of natural preservation in function closures, from next entry in this function can be accessed through the closure naturally callback execution status, the last time that we need a can awaken the interruptible object, the object is a coroutine can awaken can interrupt.

I can’t find an exact definition of coroutine, and coroutine implementations vary from language to language, but basically coroutine has two important properties

  • Can wake up interruptible functions
  • Do not take

Here we can contrast this with functions and threads

  • Compared to functions: they are wakeable and interruptible and have multiple entries
  • Compared to threads: it is non-preemptible and does not require locking of critical sections

Remember if there’s any object in js that satisfies both of these properties, obviously because JS is single threaded, it’s not preemptable, so we just need to think about the first property, and the answer is pretty obvious, Generator and Async/Await are one implementation of Coroutine.

The Generator: half coroutines

So wen zhuanlan.zhihu.com/p/98745778, as is shown in the Generator began as a simplified Iterableiterator implementation, then gradually on top of this, plus the coroutine function. While Javascript’s Generator support for Coroutine is a one-step process, Python’s Generator support for Coroutine has evolved over time. Interested can look at how the Generator in the Python evolved into Coroutine (www.python.org/dev/peps/pe… , www.python.org/dev/peps/pe… , www.python.org/dev/peps/pe… Our Generator can be used as both producer and consumer

function* range(lo, hi) { while (lo < hi) { yield lo++; } } console.log([...range(0, 5)]); / / 0,1,2,3,4,5Copy the code

Act as a Generator for consumers

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 consume: 2 consume: 3 consume: 3 consume: 4 end sum: 10 */Copy the code

For those of you familiar with RXJS, there is also an object in RXJS that can act as both a producer and a consumer called a Subject. This actually allows us to further use Generator as a pipe or delegator. Generator further supports this usage with yield * and can also be used in recursive scenarios. Here we can flatten an array with yield from support

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]]])]);
Copy the code

This approach compares to a traditional recursive implementation in that it can handle elements of infinite depth (traditional recursion dies here)

The Generator above is intended to be used more as a function that supports multiple value returns, however we will find more power if we use each Generator as a task. Zhuanlan.zhihu.com/p/24737272 before such as the author of the article, you can use the generator for OS simulation, the generator has played a major role in the field of discrete event simulation (try oneself use the generator to realize a sort animation).

While the Generator does all of the above, there is a major limitation. Observe the following code

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();

Copy the code

We find that although our Callee can voluntarily give up the time slice, the object of the next scheduling is not randomly selected, and the object of the next scheduling must be caller, which is a great limitation. It means caller can decide the scheduling of any Callee. However, callee can only dispatch the caller, and there is obvious asymmetry. Therefore, a Generator is also called an asymmetric Coroutine or a semi-coroutine (called Simple Coroutine in Python). Although we can through en.wikipedia.org/wiki/Trampo… To encapsulate a scheduler itself to decide on the next task (co is actually a Trampoline implementation) and jump to any task, but we still expect a true coroutine.

Async/Await: coroutine + Async = Coroutine that supports asynchronous task scheduling

As mentioned above, the biggest limitation of Generator is that coroutine can only yield to caller, which has great limitations in practical applications. For example, general schedulers schedule according to priority, which may be the triggering order of tasks or manually specified priority of tasks themselves. Considering that most of the web | server applications, most of the scenes are asynchronous tasks, so if you can built-in asynchronous task scheduling automatically, then can basically meet the needs of most.

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();

Copy the code

At this point, we find that we can jump between any task, such as when Task1 is scheduled to task2, and then when task2 is scheduled to Task3, the scheduling behavior is completely determined by the built-in scheduler based on the firing order of asynchronous events. While async/await is extremely convenient, there are still many limitations

  • Must be used in the async function to yield (await), async function exist up infectious, leading to top up all need to async function, can consult journal.stuffwithstuff.com/2015/02/01/…
  • Does not support priority scheduling: The scheduling rules are built-in based on event triggering sequence. In actual applications, scheduling may be required based on priority

React Fiber: A framework layer control coroutine that supports synchronization tasks and priorities

React Fiber is actually another way to implement coroutines. In fact, the React Coroutine implementation has undergone several changes such as github.com/facebook/re… Fiber, for the most part, has the same functionality as Coroutine and supports cooperative multitasking. The main difference is that Fiber is more system level while Coroutine is more Userland level. React does not directly expose operations suspend and resume, but rather coroutine scheduling at the framework level. So it might be more reasonable to call it Fiber (but presumably the more reasonable name comes from the fact that OCAML’s Algebraic effect is realized through Fiber). React with js is not directly coroutine primitives is async | await and the generator, the main reason is that

Don’t use the Async | await the reason also similarly, task scheduling in order to more fine-grained, react by fiber achieved his coroutines.

React Hooks: For Concurrency !!!

Single-threaded non-preemption: a lock-free world

React entered the world of concurrency with fiber, but the world of concurrency is full of pitfalls. Those of you who have experienced multithreaded programming probably know how difficult it is to write a thread-safe function (try writing a thread-safe singleton in c++), so why did react enter the mire? Fortunately, since Javascript is single-threaded, we naturally avoid the various Edge cases of multi-threaded parallelism, and we actually just have to deal with concurrency safety.

  • Single-thread non-preemption: means that our code between context switches is a natural critical area, and we do not need to use locks to protect the critical area, which is naturally thread-safe.

In a multi-threaded environment, any modification of a shared variable must be protected by a lock, otherwise it is not thread-safe.

The following code is always thread-safe

class Count { count = 0; add() { this.count++; }}Copy the code

Reentrancy: Shared mutable state is the root of all evil

While single threading is not a panacea, even if we get rid of the parallel preemption problem, we still need to solve the reentrant problem. Reentrant means that a function can safely support concurrent calls. In single-threaded Javascript, it does not seem possible to call a function simultaneously. In fact, this is not the case.

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]]])]);

Copy the code

For example, we pass arr = [[1]], whose call chain is as follows

Flatten ([[[1]]]) // Flatten start => Flatten ([[1]]) => Flatten ([1]Copy the code

A common non-reentrant security function is the following

const state = {
  name: "yj"
};
function test() {
  console.log("state:", state.name.toUpperCase());
  state.name = null;
}
test();
test(); // crash

Copy the code

We found that the second call was caused by the first call to secretly modify the state, while the two calls before and after test shared the external state. We must recall that this error is usually not made, so we changed the code as follows

const state = {
  name: "yj"
};

function test(props) {
  console.log("state:", state.name.toUpperCase());
  state.name = null;
}
test(state);
test(state); // state

Copy the code

Although we got rid of global variables at this point, the code still crashed because both props were actually referring to the same object

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}Copy the code

At this point, we found that our printout results were inconsistent even though we used the same button, which basically corresponds to the following React code

Function App2(){return (<> <Button /> <Yield />)} 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/> //); } ReactDOM.render( <StateProvider value={store}> <App/> </StateProvider>);Copy the code

In ConcurrentMode, it is equivalent to inserting yield statements between each adjacent Fiber node, which makes our component must ensure that the component is reentrant safe. Otherwise, the page UI may be inconsistent, or even crash the page. The main reason for the problem here is that

  • Alert and Button access the shared State variable
  • Alert during render and modifies the State variable

React officially asks users not to do any side effects during Render.

Immutable: indicates concurrency security

todo

Bytedance’s Shanghai front end team still has a large number of open positions, including school recruitment, social recruitment and internship. Its business direction involves domestic and overseas, and its business types include community, social networking, online education, Infra, etc. Welcome to contact us. Email address: [email protected], please refer to: zhuanlan.zhihu.com/p/86442059