0

I’ve been trying to develop a program that runs asynchronous tasks concurrently using Rust and the async-std crate. Because of the environment this program will run in, it strictly needs only 2 threads, both of which will make use of asynchronous code.

The code itself will control a pump with an encoder (pulse generator) for water measurement. In the future, the code will implement a simple console UI for requesting measurement operations through a channel. Right now, I have 3 async tasks, where at least 2 of them should execute simultaneously:

  • A reading task that reads the encoder and sends the pulses through a channel (Assigned to thread 1).

  • A measurement task that deals with pulse reception with a fixed timeout between pulses, considering that my pump is consistent in its flow (Assigned to thread 2).

  • An idle watch task that checks the arrival of pulses outside of the measurement operation request, both before and after (Assigned to thread 2).

The task execution flow, at least for purposes of this test, is thought as follows:

  1. Run both the reading and idle watch tasks simultaneously
  2. Make a small delay and then call the measurement task to simulate a user request (the idle watch task should be cancelled by the arrival of the measurement task)
  3. Once the measurement task is finished, restart the idle watch task

Given that the time it takes for each task to finish is different, I’m looking for a way to dynamically add tasks to some sort of queue and start running them to completion as soon as they’re added. In addition, I’d like the flexibility of performing some actions depending on which task finishes, as the return types may differ.

This is what I came up with:

use async_std::{
    channel::{self, Receiver},
    future,
    sync::Mutex,
    task::{self, JoinHandle}
};
use futures::{
    select,
    stream::FuturesUnordered,
    StreamExt
};
use std::{
    ops::Range,
    sync::Arc,
    time::{Duration, Instant}
};
use stop_token::{
    future::FutureExt,
    StopSource,
    StopToken
};

/* The following constant parameters simulate the behavior of the encoder pulse
   reading and transmission in the sender_fut task.

   - SENDER_OP_RANGE:          If the total elapsed time of the program falls within
                               this range, the sender_fut task will attempt to send
                               a pulse through the channel. It won't send a pulse if
                               the max amount of pulses has been reached. For defined
                               behavior, the range's upper limit should be smaller
                               than SENDER_OP_TOTAL_DURATION.

   - SENDER_OP_PULSE_AMOUNT:   The max amount of pulses that the sender_fut task will
                               send through the channel.

   - SENDER_OP_PULSE_INTERVAL: The time waited between each pulse.

   - SENDER_OP_TOTAL_DURATION: The total time that should elapse before exiting the
                               sender_fut task.
*/
const SENDER_OP_RANGE: Range<Duration> = Duration::from_secs(3)..Duration::from_secs(5);
const SENDER_OP_PULSE_AMOUNT: u8 = 2;
const SENDER_OP_PULSE_INTERVAL: Duration = Duration::from_millis(500);
const SENDER_OP_TOTAL_DURATION: Duration = Duration::from_secs(10);

/* The following constant parameters control the behavior of the measure operation
   in the controller_fut task.

   - MEASURE_OP_START:   The time waited at the start of the program before calling the
                         measure operation. It is equal to the amount of time the idle
                         watch operation will be active.

   - MEASURE_OP_AMOUNT:  The specified amount to measure. The gain set for each pulse
                         is 1. This way, 1 pulse = 1.0 in amount.

   - MEASURE_OP_TIMEOUT: The amount of time waited between pulses in the measure operation
                         before exiting with a timeout error.
*/
const MEASURE_OP_START: Duration = Duration::from_secs(2);
const MEASURE_OP_AMOUNT: f32 = 2.0;
const MEASURE_OP_TIMEOUT: Duration = Duration::from_secs(3);

#[async_std::main]
async fn main() {
    let mut task_collection_unit    = FuturesUnordered::<JoinHandle<()>>::new();
    let mut task_collection_result  = FuturesUnordered::<JoinHandle<Result<(), String>>>::new();
    let mut task_collection_measure = FuturesUnordered::<JoinHandle<()>>::new();

    let (tx, rx) = channel::unbounded::<bool>();

    let pump               = WaterPump;
    let encoder            = Encoder::new(rx);
    let controller         = Arc::new(Mutex::new(PumpController::new(pump, encoder)));

    let stop_src_measure   = Arc::new(Mutex::new(Some(StopSource::new())));
    let stop_src_idle      = Arc::new(Mutex::new(Some(StopSource::new())));
    let stop_token_measure = stop_src_measure.try_lock().unwrap().as_ref().unwrap().token();
    let stop_token_idle    = stop_src_idle.try_lock().unwrap().as_ref().unwrap().token();

    // This mutex is used as a semaphore to wait for idle_encoder_start() to return the Receiver
    let sync_mutex         = Arc::new(Mutex::new(()));


    let sender_fut = task::spawn(async move {
        println!("SENDER TASK START");
        let timer1 = Instant::now();
        let mut pulse_counter = 0u8;

        loop {
            let elapsed = timer1.elapsed();

            if SENDER_OP_RANGE.contains(&elapsed) && (pulse_counter < SENDER_OP_PULSE_AMOUNT) {
                tx.try_send(true).unwrap();
                task::sleep(SENDER_OP_PULSE_INTERVAL).await;
                pulse_counter += 1;
            }
            else if elapsed >= SENDER_OP_TOTAL_DURATION {
                break;
            }
        }
    });
    task_collection_unit.push(sender_fut);

    let controller_fut = {
        let sync_mutex_clone = sync_mutex.clone();
        let controller_clone = controller.clone();
        let stop_src_idle_clone = stop_src_idle.clone();

        task::spawn(async move {
            task::sleep(MEASURE_OP_START).await;

            let measure_result = controller_clone.try_lock().unwrap().measure(MEASURE_OP_AMOUNT, stop_token_measure, stop_src_idle_clone, sync_mutex_clone).await;

            match measure_result {
                Ok(_)    => println!("Measure function exit successful!"),
                Err(err) => println!("Error in measure function: {}", err)
            }
        })
    };
    task_collection_measure.push(controller_fut);

    let idle_fut = controller.try_lock().unwrap().idle_encoder_start(stop_token_idle, sync_mutex.clone());
    task_collection_result.push(idle_fut);


    loop {
        select! {
            _ = task_collection_unit.select_next_some() => {},
            _ = task_collection_measure.select_next_some() => {

                let new_stop_token_idle = stop_src_idle.try_lock().unwrap().as_ref().unwrap().token();
                task_collection_result.push(controller.try_lock().unwrap().idle_encoder_start(new_stop_token_idle, sync_mutex.clone()));

            },
            fut_result = task_collection_result.select_next_some() => {
                if let Err(err) = fut_result {
                    println!("{}", err);
                }
            },

            complete => break
        }
    }
}

struct PumpController {
    _pump: Arc<Mutex<WaterPump>>,
    encoder: Arc<Mutex<Encoder>>
}
impl PumpController {
    fn new(pump: WaterPump, encoder: Encoder) -> Self {
        PumpController {
            _pump:   Arc::new(Mutex::new(pump)),
            encoder: Arc::new(Mutex::new(encoder))
        }
    }

    fn idle_encoder_start(&self, cancel_token: StopToken, sync_mutex: Arc<Mutex<()>>) -> JoinHandle<Result<(), String>> {
        let rx = self.encoder.try_lock().unwrap().take_receiver().unwrap();
        let encoder_clone = self.encoder.clone();

        let handle = task::spawn(async move {
            println!("IDLE WATCH TASK START");
            let _guard = sync_mutex.try_lock().unwrap();
            let mut stray_pulse_counter = 0u32;

            loop {
                let token = cancel_token.clone();

                match rx.recv().timeout_at(token).await {
                    Ok(not_cancelled_result) => match not_cancelled_result {
                        Ok(some_bool) => {
                            if some_bool {
                                stray_pulse_counter += 1;
                                println!("Unexpected encoder pulse received!");
                                println!("Total stray pulses received: {}\n", stray_pulse_counter);
                            }
                        },
                        Err(_) => return Err("Channel dropped or closed".to_string())
                    },
                    Err(_) => {
                        encoder_clone.try_lock().unwrap().give_receiver(rx);
                        break;
                    }
                }
            }

            Ok(())
        });

        handle
    }

    async fn idle_encoder_stop(&self, idle_stop_src: Arc<Mutex<Option<StopSource>>>, sync_mutex: Arc<Mutex<()>>) {
        {
            let mut guard = idle_stop_src.try_lock().unwrap();
            *guard = None;
        }

        sync_mutex.lock().await;
    }

    async fn encoder_receiver_timeout(&mut self, rx: Receiver<bool>, time: Duration, limit: f32, cancel_token: StopToken) -> Result<(), String> {
        let mut exit_code = 0u8;
        let mut err_msg = "".to_string();
        let mut counter: f32 = 0.;

        loop {
            let token = cancel_token.clone();

            match future::timeout(time, rx.recv()).timeout_at(token).await {
                Ok(not_cancelled_value) => match not_cancelled_value {
                    Ok(not_timeout_value) => match not_timeout_value {
                        Ok(some_bool) => {
                            if some_bool {
                                counter += 1.;
                                println!("Received a pulse!");
                                println!("Current value: {}\n", counter);

                                if counter >= limit {
                                    break;
                                }
                            }
                        },
                        Err(_) => {
                            exit_code = 1;
                            err_msg = "Channel dropped or closed!".to_string();
                            break;
                        }
                    },
                    Err(_) => {
                        exit_code = 2;
                        err_msg = "TIMEOUT!".to_string();
                        break;
                    }
                },
                Err(_) => {
                    exit_code = 3;
                    err_msg = "Cancellation occurred!".to_string();
                    break;
                }
            }
        }

        self.encoder.try_lock().unwrap().give_receiver(rx);

        if exit_code != 0 {
            return Err(err_msg);
        }

        Ok(())
    }

    async fn measure(&mut self, amount: f32, cancel_token: StopToken, idle_stop_src: Arc<Mutex<Option<StopSource>>>, sync_mutex: Arc<Mutex<()>>) -> Result<(), String> {
        println!("MEASURE TASK START");
        self.idle_encoder_stop(idle_stop_src.clone(), sync_mutex.clone()).await;
        println!("IDLE WATCH TASK CANCELLED");

        let rx = self.encoder.try_lock().unwrap().take_receiver().unwrap();

        let timeout_duration = MEASURE_OP_TIMEOUT;
        let result = self.encoder_receiver_timeout(rx, timeout_duration, amount, cancel_token).await;

        {
            let mut guard = idle_stop_src.try_lock().unwrap();
            *guard = Some(StopSource::new());
        }

        result
    }
}

struct WaterPump;
struct Encoder { rx: Option<Receiver<bool>> }
impl Encoder {
    fn new(rx: Receiver<bool>) -> Self {
        Encoder { rx: Some(rx) }
    }

    fn take_receiver(&mut self) -> Option<Receiver<bool>> {
        self.rx.take()
    }

    fn give_receiver(&mut self, rx: Receiver<bool>) {
        self.rx = Some(rx);
    }
}

As seen from the code above, I’m using async_std::task to generate each of the desired operations. After reading the async_std crate and its documentation, I found that it’s pretty ambiguous regarding the number of threads the code will run in. Is there a way to force the execution of tasks to a single thread of my choosing, making it possible to always have only 2 threads?

Additionally, is there another way to meet the flexibility requirements I mentioned regarding the stream of tasks other than the one shown in my code? I wouldn’t consider my initial approach as a sustainable one moving forward, given that I’m planning on controlling multiple pump controllers in the future, and I feel like maintaining the code will be increasingly difficult.

RodoGM
  • 1
  • 2
    It seems you have a pretty clear picture in your head about which task should run when, I don't think an asynchronous is very well suited for that, I'd just use regular threads when my constraints are this specific, since asynchronous code abstracts much of the control away. – cafce25 Sep 01 '23 at 04:05

0 Answers0