Stop Freezing
In this stage we will make the user interface more responsive by computing the mandelbrot image on a separate thread. The user interface thread and the computation thread communicate by means of messages.
You can find the code of this stage in branch 7-responsive.
The Messages
The messages are derived from the arguments and result of the make_mandel_image function.
#![allow(unused)] fn main() { pub struct MandelReq { mapping: Mapping, coloring: Box<dyn Coloring>, } }
The message we send as request to the computation thread, contains a mapping and a coloring. Of course, we don't pass references in a message, as we did in the function call. This means we have to adapt some things in Coloring.
#![allow(unused)] fn main() { pub struct MandelReply { data: Vec<u8>, width: i32, height: i32, stride: i32, } }
The message we send as reply contains the Vec<u8> with image data and the stride, just like the return value of the function. We also provide the width and height of the picture. The receiver of the message needs this information.
The fields of the messages are private, but because we define them in lib.rs and only use them in submodules, that is no limitation.
The Channels
We use the crate 'async-channel' for communicating. This library allows us to use channels both in a synchronous and an asynchronous context. We will see below why we need that.
#![allow(unused)] fn main() { let (req_sender, req_receiver) = async_channel::unbounded(); let (reply_sender, reply_receiver) = async_channel::bounded(1); }
We have an unbounded channel for requests, because we don't want to stop the user interface thread, no matter how many requests it sends. The reply channel is bounded. It doesn't make hurt to pause the computation thread when the user interface thread has not yet handled the previous message.
The function recompute_image, which called make_mandel_image, will be split in two parts. The first part, until the call, will send the request and keep the name recompute_image. The second part, which dealt with the result, will now deal with the reply in the new function handle_new_image.
Sending the Request
#![allow(unused)] fn main() { struct State { ... req_sender: Sender<MandelReq>, } let state = Rc::new(RefCell::new(State::new(req_sender))); }
In the state we store the request sender. The function new takes a parameter now for this purpose.
#![allow(unused)] fn main() { fn recompute_image(&mut self) { let coloring = self.color_info.scheme(self.col_idx).clone(); let request = MandelReq { mapping: self.mapping.clone(), coloring, }; let _ = self.req_sender.send_blocking(request); } }
The function recompute_image in State now just sends a request to the computation thread.
We clone the coloring and mapping and store it in a message. We use the function send_blocking to send the message. This function can be used in the synchronous context we have here. It blocks until the message is sent, but because we have an unbounded queue, that won't take long.
Receiving the Reply
The user interface thread has to receive the replies and act upon them.
#![allow(unused)] fn main() { glib::spawn_future_local(new_image_handler(reply_receiver, state)); async fn new_image_handler(reply_receiver: Receiver<MandelReply>, state: Rc<RefCell<State>>) { while let Ok(reply) = reply_receiver.recv().await { let img = Image::new(reply.data, IMG_FMT, reply.width, reply.height, reply.stride); state.borrow_mut().set_img(img); } } }
Glib offers the function spawn_future_local which makes it possible to run code concurrently in the user interface thread. It takes a future, which is what we get from the async function new_image_handler. This is the only place in the code where the keyword async occurs, so we'll spend a few words on it.
The function new_image_handler receives messages in a loop and hands them over to be processed. Because it runs in an asynchonous context, it uses the the function recv. This function returns a future, which we await. That means, that if there is no message to receive, the function will suspend and the thread (the user interface thread) will move on doing other things. It will be notified when the function has something to receive, so that the function can be resumed. This kind of cooperative concurrency makes it possible that a single thread both waits for messages and handles user interaction.
#![allow(unused)] fn main() { fn handle_new_image(reply: MandelReply, state: &mut State) { state.img = Some(Image::new( reply.data, IMG_FMT, reply.width, reply.height, reply.stride, )); if let Some(canvas) = state.canvas.upgrade() { canvas.queue_draw(); } } }
The function handle_new_image handles one reply. It constructs the image, puts it in the state and notifies the canvas that it should redraw. Notice that it is vital that this function runs on the user interface threads, otherwise all those operations would be illegal.
The Computation Thread
We want the code that computes the Mandelbrot images to run on a separate thread.
#![allow(unused)] fn main() { gio::spawn_blocking(move || mandel_producer(req_receiver, reply_sender)); }
Gio has a thread pool that we can use to for this purpose. We hand it a closure that captures the request receiver and reply sender.
#![allow(unused)] fn main() { pub fn mandel_producer( req_receiver: async_channel::Receiver<MandelReq>, reply_sender: async_channel::Sender<MandelReply>, ) { loop { let mut request; match req_receiver.recv_blocking() { Err(_) => break, Ok(new_request) => { request = new_request; } } request = last_request(request, &req_receiver); if let Some((data, stride)) = make_mandel_image( &request.mapping, &request.coloring) { let _ = reply_sender.send_blocking(MandelReply { data, width: request.mapping.win_width as i32, height: request.mapping.win_height as i32, stride, }); } } } }
The function mandel_producer listens in a loop for requests. We use recv_blocking, because we are in a synchonous context and actually need to block if there is no message. If an error occurs, we leave the loop. This should only occur if the channel was closed by the sender.
There may be several requests waiting. If there are, we throw away all but the last and process that one.
Next, we compute the image with the help of make_mandel_image. We construct a reply message with the data returned and the width and height that are available in the request. We use send_blocking, that may indeed block us, because of the bounded reply channel. But as soon as the user interface thread catches up, we will proceed. If something goes wrong when making the image, we will just not send anything.
#![allow(unused)] fn main() { fn last_request( mut request: MandelReq, req_receiver: &async_channel::Receiver<MandelReq>, ) -> MandelReq { loop { match req_receiver.try_recv() { Ok(new_request) => { request = new_request; } Err(_) => return request, } } } }
The function last_request will replace the input request by the last request in the queue. In a loop we call try_recv. If there is no message in the queue, this will return an Err and we will return the request we have. Otherwise we replace the request and continue to look for more messages.
Changes to Coloring
In the function recompute_image we cloned a Box<dyn Coloring> and then sent it over a channel. We need to do something to make this possible.
#![allow(unused)] fn main() { pub trait Coloring: DynClone + Sync + Send { ... } dyn_clone::clone_trait_object!(Coloring); }
The crate dyn-clone supplies everything that we need. And it contains all explanations that you need, so please follow the link if you want to know more.