Kernel

In the context of lparallel, a kernel is an abstract entity that schedules and executes tasks. The lparallel kernel API is meant to describe parallelism in a generic manner.

The implementation uses a group of worker threads. It is intended to be efficiency-wise comparable to (or faster than) similar hand-rolled solutions while also providing full condition handling and consistency checks. All higher-level constructs in lparallel are implemented on top of the kernel.

(For an implementation of the kernel API that distributes across machines, see lfarm.)

Kernel-related operations are applied to the current kernel, stored in *kernel*. A kernel is typically created with

(setf lparallel:*kernel* (lparallel:make-kernel N))

where N is the number of worker threads (more options are available). In most circumstances a kernel should exist for the lifetime of the Lisp process. Multiple kernels are possible, and setting the current kernel is done in the expected manner by dynamically binding *kernel*.

A task is a function designator together with arguments to the function. To execute a task, (1) create a channel, (2) submit the task through the channel, and (3) receive the result from the channel.

(defpackage :example (:use :cl :lparallel))
(in-package :example)

(let ((channel (make-channel)))
  (submit-task channel '+ 3 4)
  (receive-result channel))

; => 7

If you have not created a kernel (if *kernel* is nil) then upon evaluating the above you will receive an error along with a restart offering to make a kernel for you. Evaluation commences once a kernel is created.

Multiple tasks may be submitted on the same channel, though the results are not necessarily received in the order in which they were submitted. receive-result receives one result per call.

(let ((channel (make-channel)))
  (submit-task channel '+ 3 4)
  (submit-task channel (lambda () (+ 5 6)))
  (list (receive-result channel)
        (receive-result channel)))

; => (7 11) or (11 7)

To set the priority of tasks, bind *task-priority* around calls to submit-task.

(let ((*task-priority* :low))
  (submit-task channel '+ 3 4))

The kernel executes a :low priority task only when there are no default priority tasks pending. The task priorities recognized are :default (the default priority) and :low.

Handlers may be established for conditions that are signaled by a task (see Handling). When an error from a task goes unhandled, an error-info object is placed in the channel. After receive-result removes such an object from the channel, the corresponding error is signaled.

Note that a kernel will not be garbage collected until end-kernel is called.

Message passing

For situations where submit-task and receive-result are too simplistic, a blocking queue is available for arbitrary message passing between threads.

(defpackage :queue-example (:use :cl :lparallel :lparallel.queue))
(in-package :queue-example)

(let ((queue (make-queue))
      (channel (make-channel)))
  (submit-task channel (lambda () (list (pop-queue queue)
                                        (pop-queue queue))))
  (push-queue "hello" queue)
  (push-queue "world" queue)
  (receive-result channel))
;; => ("hello" "world")

Of course messages may also be passed between workers.

Dynamic variables and worker context

When a dynamic variable is dynamically bound (for example with let or progv), the binding becomes local to that thread. Otherwise, the global (default) value of a dynamic variable is shared among all threads that access it.

Binding dynamic variables for use inside tasks may be done on either a per-task basis or a per-worker basis. An example of the former is

(submit-task channel (let ((foo *foo*))
                       (lambda ()
                         (let ((*foo* foo))
                           (bar)))))

This saves the current value of *foo* and, inside the task, binds *foo* to that value for the duration of (bar). You may wish to write a submit-with-my-bindings function to suit your particular needs.

To establish permanent dynamic bindings inside workers (thread-local variables), use the :bindings argument to make-kernel, which is an alist of (var-name . value-form) pairs. Each value-form is evaluated inside each worker when it is created. (So if you have two workers, each value-form will be evaluated twice.)

For more complex scenarios of establishing worker context, a :context function may be provided. This function is called by lparallel inside each worker and is responsible for entering the worker loop by funcalling its only parameter. The variables from :bindings are available inside the function.

(defvar *foo* 0)
(defvar *bar* 1)

(defun my-worker-context (worker-loop)
  (let ((*bar* (1+ *foo*)))
    ;; enter the worker loop; return when the worker shuts down
    (funcall worker-loop)))

(defvar *my-kernel* (make-kernel 2
                                 :bindings '((*foo* . (1+ 98)))
                                 :context #'my-worker-context))

(list *foo* *bar*)
; => (0 1)

(let* ((*kernel* *my-kernel*)
       (channel (make-channel)))
  (submit-task channel (lambda () (list *foo* *bar*)))
  (receive-result channel))
; => (99 100)