Note: this article is part of the "Building a graphical multi-user spreadsheet editor in Zig" series. Read all the articles here:
Note: this post is a bit dense, as I try to explain the whole thought process, and because some parts are based on design choices I haven't yet explained (I give some hints when it happens, but further explanations will come in other posts). Styling cues and an ASCII diagram at the end of the post might help to follow along, but feedback is welcome if this isn't enough.
In a previous post, I experimented with multithreading, as there are many things to get done at the same time in a spreadsheet editor (collecting user input, communicating with the server, synchronizing states, computing new cell values).
It's now time to design the architecture of grid client, in particular the different threads and how they exchange data. The goal is to minimize user-perceived latency, and to support both local mode and multi-user mode.
First, one thread should be dedicated to collecting user input and displaying the interface as quickly as possible. It's better to show the user some data is not ready than to make it wait (generally speaking, the user should never wait). This thread is referred to as the controller and view.
Alongside this, it is also needed to update the content of the file according to modifications. I'll go into more detail about how content is synchronized between clients in a future post, but here is the general idea:
The thread maintaining the content state is referred to as the state manager.
Lastly, the client needs to communicate with the server in multi-user mode. Communications are asynchronous, which calls for two additional threads: the sender and the receiver, both using the same socket. Using separate threads also allows the socket to be used in blocking mode, which is easier and fully in line with the architecture.
As seen in the first multithreading post, it is important to wonder about what and how data will be exchanged between threads.
The controller can issue new modifications to both the server (via the sender) and the state manager. As the sender and state manager can be in a busy state, and the controller can't delay user input, inter-thread communication must be asynchronous. Similarly when the receiver receives a certified modification or a modification [in]validation, it should forward it to the state manager and resume socket reading as soon as possible.
For this kind of asynchronous communication, multithreading-safe queues will be used. Each queue has exactly one publisher and one consumer. The memory must be allocated by the publisher thread, and freed by the consumer thread.
However, asynchronous communication isn't always possible. For example, when the user scroll, the controller has to know almost instantly the content of the discovered cells. It could ask the state manager, but there is a problem: the state manager might not be available! This calls for a cache, whose purpose will be to store the last known state of a limited number of cells, so that the controller can display them fast. A new thread must then be created, to ensure the cache content is up to date. It is referred to as the cache manager.
But back to the communication question. In some cases, the controller wants the content owned by another thread (the cache manager) instantly, in other words in a synchronous way. That's why the threads, considered as entities with their respective internal data, can offer "services" to other threads: functions executed by a calling thread but using the callee thread data. Threads offering such services must ensure the high availability of the shared ressources (so that the service doesn't block the calling thread), as well as the multithreading safety.
To sum up, threads can exchange data in two ways:
Before listing the tasks of each thread, here is an ASCII diagram to show all of the inter-thread exchanges:
+------------+ modif attempt +--------+
draw_cell()| CONTROLLER | ---------------> | SENDER | server
/ | AND VIEW | cursor pos | | - - >
/ / +------------+ ---------------> +--------+
/ / | | draw_cursor_pos()
/ / | | \
/ / write | | local \
/ / request | | modif \
/ / | | \
/ get_area() v v \
+---------+ area request +---------+ approved modif +----------+
| CACHE | ---------------> | STATE | <--------------- | RECEIVER | server
| MANAGER | cell update | MANAGER | [in]validation | | < - -
+---------+ <--------------- +---------+ <--------------- +----------+
Legend: \----- ONLY IN MULTI-USER MODE -----/
box thread
--------> asynchronous queue
fn() \ \ synchronous service
- - - - > socket communication with server
The controller and view:
The state manager:
The cache manager:
Note: if this is a performance bottleneck, work can be done to minimize the number of cells updates sent by the state manager to the cache manager (so that the latter does not have to process them all).
The sender encodes the data sent by other threads according to a protocol, and sends it to the server via a blocking socket. It can be:
The receiver receives the data sent by the server via a blocking socket, decodes it according to a protocol, and acts accordingly or forwards it to other threads. It can be: