PicoLixp Explored: On Coroutines

PicoLixp Explored: On Coroutines


5 min read

In this post, we will introduce the concept of co-routines and how they are handled in PicoLisp. This will be the foundation for the subsequent posts on Discrete Event Simulation and finally a couple of examples, including an ASCII model railway simulation written in PicoLisp, so stay tuned ๐Ÿค—

What are Coroutines?

Coroutines are a type of programming construct that aim to achieve concurrency in programming without using threads or processes. The key idea of coroutines is that their execution can be paused and resumed at specific points during their execution, allowing other coroutines to run in the meantime. This requires that the local environment as well as the state of control of the function are preserved while the coroutines are stopped.

One of the benefits of coroutines is that they can be used to write asynchronous code in a more sequential and readable manner, compared to other concurrency models such as threads or callbacks. Coroutines can be used to write code that looks like synchronous code, while still taking advantage of asynchronous behavior and avoiding some of the pitfalls of concurrent programming, such as race conditions and deadlocks.

Co-Routines in PicoLisp

We need two functions to create a coroutine in PicoLisp: co to initiate the coroutine, and yield, which controls when the function is stopped and resumed.

From the documentation:

(co ['any [. prg]]) -> any

Starts, resumes or stops a coroutine with the tag given by any. (...) prg will be executed until it either terminates normally or until yield is called.

Let's look at a very simple co-routine:

(co 'a
    (let N 0
                (inc 'N) ) ) ) )

According to the definition, we have two parts: a is the tag of the coroutine, and (let N 0 (loop (yield (inc 'N))))) is its program body. If we call it for the first time, N will be set to zero and then increased by one in a loop. Then yield interrupts the coroutine and returns the value.

Let's test it in the REPL:

: (co 'a (let N 0 (loop (yield (inc 'N))))) 
-> 1

As expected, the return value is 1, because N has been increased once.

Now comes the point: Next time we call the coroutine a, it will resume where we stopped. The syntax to resume is (co <tag> <anything>) , for example:

: (co 'a T) 
-> 2
: (co 'a T)
-> 3

Note: Theanythingpart can be literally anything as it does not get executed. For example, this one will also work but does not make sense:

: (co 'a (println "this will not print anything"))
-> 4

To stop the coroutine, we call (co <tag>) without any argument. If then the coroutine gets called again, it will start from the beginning.

: (co 'a) 
-> a
: (co 'a (let N 0 (loop (yield (inc 'N))))) 
-> 1

Managing the Stack

To keep the local environment for each coroutine, each one of them needs its own stack space to maintain its state. This means that for large and nested coroutines, it might be necessary to monitor the stack size. By default, the stack segment size is 64 kB for coroutines and 256 kB for the main stack segment.

The current stack can be viewed like this:

: (stack)
-> ((a . 63) (T . 250) . 64)

In the output above we see our coroutine a and the main routine (which also maintains the REPL) T, with the respective unused stack space, plus the general coroutine stack size:

  • coroutine a has unused stack space of 63 kB (out of 64),

  • the main routine T has unused stack space of 250 kB (out of 256 kB),

  • The allocated stack size of each coroutine is 64 kB.

In some situations, you might want to adjust the stack space. This is done by passing arguments to the stack function, where the first argument is the coroutine stack size and the second (optional) argument is the main stack size. The coroutine stack size can only be modified if no coroutine is running.

: (co 'a)   # stop current coroutine
-> a
: (stack 128 512)
-> 128

If we then start the coroutine again, we will see the updated stack sizes:

: (co 'a (let N 0 (loop (yield (inc 'N)))))   # restart coroutine
-> 1
: (stack)
-> ((a . 127) (T . 507) . 128)

Obviously we cannot allocate more stack space to a coroutine than is allocated by the system to the picolisp process. The default stack space settings can be checked with the ulimit -s command in Linux systems:

$ ulimit -s

In the example above, it is 8192 kB (8 MB). If we try to allocate more than 8 MB to the coroutines, we will get a segmentation fault error:

: (stack 4200 4200)
-> 4200
: (co 'a T)
[1]    17524 segmentation fault (core dumped)  ~/pil21/pil +

To prevent this, we can increase the stack space for the PicoLisp process with ulimit -s <stack space>:

$ ulimit -s 16384   # 16 MB
$ ulimit -s unlimited

Use with care, as allocating too many resources to a process might exhaust your system's resource limits and lead to unexpected behavior.

If you find this "increase a number" example a little bit boring, wait for the next posts - we will see some more useful examples on how to use coroutines soon.