Skip to content

Cooperative concurrency for Emacs Lisp using coroutines - write sequential-looking async code with yield, coroutine-local variables, and structured control flow.

License

Notifications You must be signed in to change notification settings

ctwhite/coroutines

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 

Repository files navigation

Coroutines: Cooperative Concurrency in Emacs Lisp

A lightweight Emacs Lisp library providing core coroutine primitives for cooperative concurrency.

Introduction

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.

Core Concepts

The coroutines library introduces several key concepts and functions for defining and controlling coroutines:

defcoroutine! NAME ARGS &rest BODY

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))))

resume! RUNNER

Resumes a coroutine from its last suspension point. This is used when you don't need to send a value back into the coroutine.

send! RUNNER VALUE

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.

receive!

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.

throw! TAG VAL

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.

catch! TAG &rest BODY

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.

Introspection Functions

  • coroutines:status RUNNER: Returns the current execution status (:running, :done, :error, :cancelled).

  • coroutines:done? RUNNER: Returns t if the coroutine has finished executing.

  • coroutines:cancelled? RUNNER: Returns t if the coroutine was cancelled.

  • coroutines:name RUNNER: Returns the symbol name of the coroutine function.

Quick Start & Examples

To use the coroutines library, ensure coroutines.el (which will pull in yield.el and its dependencies) is loaded.

Example 1: Basic Coroutine with resume! and send!

(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

Example 2: Coroutine with throw! and catch!

(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!

Installation

Manual Installation

Place all .el files (or their compiled .elc counterparts) into a directory that is part of your Emacs load-path.

Using straight.el with use-package

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)

Contributing & Feedback

We welcome contributions, bug reports, and feedback! If you encounter any issues or have suggestions for improvements, please reach out.

About

Cooperative concurrency for Emacs Lisp using coroutines - write sequential-looking async code with yield, coroutine-local variables, and structured control flow.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published