common lisp lparallel

2023-08-10

lparallel is a multithreading library for common lisp, it is useful because the ANSI CL standard doesnt have multithreading
it is available via quicklisp:

(ql:quickload :lparallel)

to get the number of cores we have to use the serapeum package:

(ql:quickload :serapeum)

usage:

(serapeum:count-cpus)
16

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.
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, which ideally would be equal to the number of cores obtained using (serapeum:count-cpus).
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 (with let, for example).
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.

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

a channel is simply a means to communicate with the kernel
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.
some examples:

(let ((channel (lparallel:make-channel)))
  (lparallel:submit-task channel '+ 3 4)
  (lparallel:submit-task channel (lambda () (+ 5 6)))
  (list (lparallel:receive-result channel)
        (lparallel:receive-result channel)))
11 7
(let ((channel (lparallel:make-channel)))
  (lparallel:submit-task channel #'sleep 1)
  (lparallel:submit-task channel (lambda () (sleep 3) (+ 7 6)))
  (lparallel:submit-task channel (lambda () (+ 4 6)))
  (print (lparallel:receive-result channel))
  (print (lparallel:receive-result channel))
  (print (lparallel:receive-result channel)))

10 
NIL 
13 

this would take full advantage of 3 cores if the kernel was created with 3+ workers (dont run it!):

(let ((channel (lparallel:make-channel)))
  (lparallel:submit-task channel (lambda () (loop)))
  (lparallel:submit-task channel (lambda () (loop)))
  (lparallel:submit-task channel (lambda () (loop)))
  (print (lparallel:receive-result channel)))

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

(lparallel:end-kernel)

plet

plet might be the simplest construct that lparallel offers for asynchronous computation, consider the following example:

(lparallel:plet ((a (progn (sleep 1) 9)))
  (print "this runs instantly")
  (print a)) ; => 9

once my-slow-function is done running, the returned value, 9, will be in a and the form (print a) would be executed, but until then, execution hangs on (print a), notice that the first print statement is executed instantly because it doesnt depend on a variable (the variable a, in this case) that was defined using plet, which depends on a task to finish executing
although notice that, the whole sexp does pause and doesnt execute asynchronously, consider this example:

(lparallel:plet ((a (progn (sleep 1) 9)))
  (format t "hi~%")
  (format t "~A~%" a))
(format t "~A~%" 10)
hi
9
10

the next sexp after plet doesnt run until plet is done.

promise

promises and futures are also useful
a promise is a "promise" that needs to be fulfill'ed, fullfilledp checks whether a promise has been fulfilled, fulfill turns a promise into a fulfilled one, and attaches the second argument as its return value, force returns the value returned by a promise
example:

(let ((p (lparallel:promise)))
  (lparallel:fulfilledp p) ; => nil
  (lparallel:fulfill p 3)
  (lparallel:fulfilledp p) ; => t
  (lparallel:force p)) ; => 3

a future is a promise which is fulfilled in parallel, it takes forms to execute asynchronously, fulfilledp is used to check whether a future is done executing the forms, and force is used to get the result of the execution, if the promise (the future) is not yet fulfilled (hasnt done executing), the call to force block execution until it is, example:

(let ((f (lparallel:future (sleep 0.2) (+ 3 4))))
  (lparallel:fulfilledp f) ; => nil
  (sleep 0.4)
  (lparallel:fulfilledp f) ; => t
  (lparallel:force f)) ; => 7