A lightweight Emacs Lisp library providing core coroutine primitives for cooperative concurrency.
coroutines brings the power of coroutines to Emacs Lisp, enabling structured, sequential-looking code that can be interleaved with other operations. This offers a more intuitive way to manage complex, suspendable logic without resorting to callback-heavy or deeply nested code.
Coroutines are ideal for tasks such as:
-
Cooperative Multitasking: Writing multiple "threads" of execution that explicitly yield control to each other.
-
Stateful Processes: Maintaining complex internal state across multiple suspension points.
-
Asynchronous Operations: Orchestrating a sequence of asynchronous steps in a linear fashion.
-
Complex Iterators: Building sophisticated data processing pipelines that can be paused and resumed.
This library is built upon the yield generator framework, leveraging its robust Continuation-Passing Style (CPS) transformation and Finite State Machine (FSM) runtime to provide a seamless coroutine experience.
The coroutines library introduces several key concepts and functions for defining and controlling coroutines:
This macro defines a coroutine function. When called, it returns a new coroutine runner function. This runner is the interface through which you interact with the coroutine using resume! and send!.
The ARGS list can optionally include a :locals keyword followed by a plist. Variables defined in :locals are coroutine-local, meaning their state is preserved across yield! points for that specific coroutine instance.
Example:
(defcoroutine! my-counter (start)
:locals (count nil)
"A simple counting coroutine."
(setq count start)
(while t
(yield! count) ; Yield the current count
(setq count (1+ count))))
Resumes a coroutine from its last suspension point. This is used when you don't need to send a value back into the coroutine.
Resumes a coroutine and simultaneously sends VALUE back into it. The VALUE will be the return value of the yield! or receive! expression that previously suspended the coroutine.
Must be called from within a running coroutine. It retrieves the value most recently sent to the current coroutine via send!. This is how a coroutine can get input from its caller.
Performs a non-local exit (like a throw in Common Lisp or a raise in Python) from a coroutine. This allows for structured error handling and control flow that can bypass multiple yield! points.
Catches a coroutine-level throw! with a matching TAG. This provides robust error handling within coroutines, allowing you to wrap sections of code where a throw! might occur.
-
coroutines:status RUNNER: Returns the current execution status (:running,:done,:error,:cancelled). -
coroutines:done? RUNNER: Returnstif the coroutine has finished executing. -
coroutines:cancelled? RUNNER: Returnstif the coroutine was cancelled. -
coroutines:name RUNNER: Returns the symbol name of the coroutine function.
To use the coroutines library, ensure coroutines.el (which will pull in yield.el and its dependencies) is loaded.
(require 'coroutines)
(defcoroutine! dialog-bot (name)
:locals (question-count 0)
"A simple bot that asks questions and processes replies."
(message "Bot: Hello, %s! Let's chat." name)
(while (< question-count 3)
(setq question-count (1+ question-count))
(yield! (format "Bot: What is your favorite number? (Question %d)" question-count))
(let ((reply (receive!))) ; Receive reply from send!
(message "Bot: You said: %S. Interesting!" reply))))
(message "Bot: That's all for now. Goodbye!")
;; Create a bot instance
(setq my-bot (dialog-bot "Alice"))
;; Start the bot
(resume! my-bot)
;; => (:yield t :value "Bot: Hello, Alice! Let's chat.") ; Initial message
;; -> Next, will yield the first question.
(resume! my-bot)
;; => (:yield t :value "Bot: What is your favorite number? (Question 1)")
;; Send a reply back to the bot
(send! my-bot 42)
;; => Bot: You said: 42. Interesting!
;; => (:yield t :value "Bot: What is your favorite number? (Question 2)")
(send! my-bot "Seven")
;; => Bot: You said: "Seven". Interesting!
;; => (:yield t :value "Bot: What is your favorite number? (Question 3)")
(send! my-bot '(list 1 2 3))
;; => Bot: You said: (1 2 3). Interesting!
;; => Bot: That's all for now. Goodbye!
;; => (:done t :value nil) ; Coroutine has finished
(require 'coroutines)
(defcoroutine! data-processor (source)
:locals (processed-count 0)
"Processes data from a source, with error handling."
(catch! 'stop-processing
(cl-loop for item in source do
(cond
((equal item 'error)
(message "Processor: Encountered an error item. Throwing 'processing-error!")
(throw! 'processing-error "Invalid item"))
((equal item 'quit)
(message "Processor: Received 'quit' command. Throwing 'stop-processing!")
(throw! 'stop-processing "Quit command received"))
(t
(setq processed-count (1+ processed-count))
(yield! (format "Processed: %S (Total: %d)" item processed-count)))))
(message "Processor: All items processed normally.")) ; This won't be reached if 'quit' is thrown
(message "Processor: Finally exited catch block."))
;; Test case 1: Normal processing
(setq proc1 (data-processor '(1 2 3)))
(resume! proc1) ; => (:yield t :value "Processed: 1 (Total: 1)")
(resume! proc1) ; => (:yield t :value "Processed: 2 (Total: 2)")
(resume! proc1) ; => (:yield t :value "Processed: 3 (Total: 3)")
(resume! proc1) ; => Processor: All items processed normally.
; Processor: Finally exited catch block.
; => (:done t :value nil)
;; Test case 2: Throwing an error
(setq proc2 (data-processor '(1 error 3)))
(resume! proc2) ; => (:yield t :value "Processed: 1 (Total: 1)")
(resume! proc2) ; => Processor: Encountered an error item. Throwing 'processing-error!
; *** Debugger entered with message: (error "Invalid item")
; -> Coroutine signals error because 'processing-error is not caught within.
;; Test case 3: Catching a custom throw
(defcoroutine! supervised-processor (source)
"Processor that catches internal 'processing-error."
(catch! 'processing-error ; This catch block is for internal errors
(catch! 'stop-processing ; This catch block is for external stop signals
(cl-loop for item in source do
(cond
((equal item 'error)
(message "Supervised: Encountered an error item. Throwing 'processing-error' internally!")
(throw! 'processing-error "Bad data detected"))
((equal item 'stop)
(message "Supervised: Received 'stop' command. Throwing 'stop-processing'!")
(throw! 'stop-processing "External stop"))
(t
(yield! (format "Supervised processed: %S" item)))))
(message "Supervised: Loop finished normally."))
(message "Supervised: Caught 'stop-processing' or loop completed."))
(message "Supervised: Exited outer 'processing-error' catch block. Status: %S" (coroutines:status (coroutine--get-current-ctx))))
(setq proc3 (supervised-processor '(1 2 error 4 5)))
(resume! proc3) ; => (:yield t :value "Supervised processed: 1")
(resume! proc3) ; => (:yield t :value "Supervised processed: 2")
(resume! proc3) ; => Supervised: Encountered an error item. Throwing 'processing-error' internally!
; => Supervised: Exited outer 'processing-error' catch block. Status: :running
; -> Coroutine just finished its internal `catch!`, but then the outer `catch!` block finishes,
; and the coroutine will resume where it left off, but nothing more to process in this example.
; It returns the value thrown by the `throw!`.
; => (:yield t :value "Bad data detected") ; Value of the throw!
;; Example of throwing 'stop-processing' from outside
(setq proc4 (supervised-processor '(1 2 3 stop 5)))
(resume! proc4) ; => (:yield t :value "Supervised processed: 1")
(resume! proc4) ; => (:yield t :value "Supervised processed: 2")
(send! proc4 'stop) ; Send 'stop' value. Processor yields 'stop' and the coroutine catches it.
;; => Supervised: Received 'stop' command. Throwing 'stop-processing'!
;; => Supervised: Caught 'stop-processing' or loop completed.
;; => Supervised: Exited outer 'processing-error' catch block. Status: :running
;; => (:yield t :value "External stop") ; Value of the throw!
Place all .el files (or their compiled .elc counterparts) into a directory that is part of your Emacs load-path.
For users of straight.el, you can easily install coroutines by adding the following to your Emacs initialization file:
(use-package coroutines
:straight (coroutines :type git :host github)We welcome contributions, bug reports, and feedback! If you encounter any issues or have suggestions for improvements, please reach out.