Chapter 15. Thread System

This chapter describes the Chez Scheme thread-system procedures and syntactic forms. With the exception of locks, the features of the thread system are implemented on top of the Posix thread system (pthreads). Consult the pthreads documentation on your system for basic details of thread creation and interaction.

Most primitive Scheme procedures are thread-safe, meaning that they can be called concurrently from multiple threads. This includes allocation operations like cons and make-string, accessors like car and vector-ref, numeric operators like + and sqrt, and nondestructive higher-level primitive operators like append and map.

Simple mutation operators, like set-car!, vector-set!, and record field mutators are thread-safe. Likewise, assignments to local variables, including assignments to (unexported) library and top-level program variables are thread-safe.

Other destructive operators are thread safe only if they are used to operate on different objects from those being read or modified by other threads. For example, assignments to global variables are thread-safe only as long as one thread does not assign the same variable another thread references or assigns. Similarly, putprop can be called in one thread while another concurrently calls putprop or getprop if the symbols whose property lists are being modified or accessed differ.

In this context, most I/O operations should be considered destructive, since they might modify a port's internal structure; see also Section 15.6 for information on buffered ports.

Use of operators that are not thread-safe without proper synchronization can corrupt the objects upon which they operate. This corruption can lead to incorrect behavior, memory faults, and even unrecoverable errors that cause the system to abort.

The compiler and interpreter are thread-safe to the extent that user code evaluated during the compilation and evaluation process is thread-safe or properly synchronized. Thus, two or more threads can call any of the compiler or interpreter entry points, i.e., compile, compile-file, compile-program, compile-script, compile-port, or interpret at the same time. Naturally, the object-file targets of two file compilation operations that run at the same time should be different. The same is true for eval and load as long as the default evaluator is used or is set explicitly to compile, interpret, or some other thread-safe evaluator.

One restriction should be observed when one of multiple threads creates or loads compiled code, however, which is that only that thread or subsequently created children, or children of subsequently created children, etc., should run the code. This is because multiple-processor systems upon which threaded code may run might not guarantee that the data and instruction caches are synchronized across processors.

Section 15.1. Thread Creation

procedure: (fork-thread thunk)
returns: a thread object
libraries: (chezscheme)

thunk must be a procedure that accepts zero arguments.

fork-thread invokes thunk in a new thread and returns a thread object.

Nothing can be done with the thread object returned by fork-thread, other than to print it.

Threads created by foreign code using some means other than fork-thread must call Sactivate_thread (Section 4.8) before touching any Scheme data or calling any Scheme procedures.

procedure: (thread? obj)
returns: #t if obj is a thread object, #f otherwise
libraries: (chezscheme)

procedure: (get-thread-id)
returns: the thread id of the current thread
libraries: (chezscheme)

The thread id is a thread number assigned by thread id, and has no relationship to the process id returned by get-process-id, which is the same in all threads.

Section 15.2. Mutexes

procedure: (make-mutex)
returns: a new mutex object
libraries: (chezscheme)

procedure: (mutex? obj)
returns: #t if obj is a mutex, #f otherwise
libraries: (chezscheme)

procedure: (mutex-acquire mutex)
procedure: (mutex-acquire mutex block?)
returns: see below
libraries: (chezscheme)

mutex must be a mutex.

mutex-acquire acquires the mutex identified by mutex. The optional boolean argument block? defaults to #t and specifies whether the thread should block waiting for the mutex. If block? is omitted or is true, the thread blocks until the mutex has been acquired, and an unspecified value is returned.

If block? is false and the mutex currently belongs to a different thread, the current thread does not block. Instead, mutex-acquire returns immediately with the value #f to indicate that the mutex is not available. If block? is false and the mutex is successfully acquired, mutex-acquire returns #t.

Mutexes are recursive in Posix threads terminology, which means that the calling thread can use mutex-acquire to (re)acquire a mutex it already has. In this case, an equal number of mutex-release calls is necessary to release the mutex.

procedure: (mutex-release mutex)
returns: unspecified
libraries: (chezscheme)

mutex must be a mutex.

mutex-release releases the mutex identified by mutex. Unpredictable behavior results if the mutex is not owned by the calling thread.

syntax: (with-mutex mutex body1 body2 ...)
returns: the values of the body body1 body2 ...
libraries: (chezscheme)

with-mutex evaluates the expression mutex, which must evaluate to a mutex, acquires the mutex, evaluates the body body1 body2 ..., and releases the mutex. The mutex is released whether the body returns normally or via a control operation (that is, throw to a continuation, perhaps because of an error) that results in a nonlocal exit from the with-mutex form. If control subsequently returns to the body via a continuation invocation, the mutex is reacquired.

Using with-mutex is generally more convenient and safer than using mutex-acquire and mutex-release directly.

Section 15.3. Conditions

procedure: (make-condition)
returns: a new condition object
libraries: (chezscheme)

procedure: (thread-condition? obj)
returns: #t if obj is a condition object, #f otherwise
libraries: (chezscheme)

procedure: (condition-wait cond mutex)
returns: unspecified
libraries: (chezscheme)

cond must be a condition object, and mutex must be a mutex.

condition-wait waits for the condition identified by the condition object cond. The calling thread must have acquired the mutex identified by the mutex mutex at the time condition-wait is called. mutex is released as a side effect of the call to condition-wait. When a thread is later released from the condition variable by one of the procedures described below, mutex is reacquired and condition-wait returns.

procedure: (condition-signal cond)
returns: unspecified
libraries: (chezscheme)

cond must be a condition object.

condition-signal releases one of the threads waiting for the condition identified by cond.

procedure: (condition-broadcast cond)
returns: unspecified
libraries: (chezscheme)

cond must be a condition object.

condition-broadcast releases all of the threads waiting for the condition identified by cond.

Section 15.4. Locks

Locks are more primitive but more flexible and efficient than mutexes and can be used in situations where the added mutex functionality is not needed or desired. They can also be used independently of the thread system (including in nonthreaded versions of Chez Scheme) to synchronize operations running in separate Scheme processes as long as the lock is allocated in memory shared by the processes.

A lock must be explicitly allocated in memory that resides outside the Scheme heap and, when appropriate, explicitly deallocated. Once allocated and initialized, a process or thread can attempt to acquire the lock. Once the lock has been acquired and before it is released, further attempts to acquire the lock, even by the process or thread that most recently acquired the lock, fail. Locks can be released by any process or thread, not just by the process or thread that most recently acquired the lock.

The lock mechanism provides little structure, and mistakes in their allocation and use can lead to memory faults, deadlocks, and other problems. Thus, it is usually advisable to use locks only as part of a higher-level abstraction that ensures locks are used in a disciplined manner.

ftype: ftype-lock
libraries: (chezscheme)

ftype-lock is an ftype (Section 4.5) that describes the foreign type of a lock object. To create a lock object, a program should use a make-ftype-pointer (page 76) form with ftype-lock as the ftype and an the address of some block of memory of size (ftype-sizeof ftype). The block of memory should be allocated outside the Scheme heap in some area shared by the threads or processes that will interact with the lock. When just threads are involved (i.e., when multiple processes are not involved), the memory can be allocated via foreign-alloc, e.g.:

(define lock
  (make-ftype-pointer ftype-lock
    (foreign-alloc (ftype-sizeof ftype-lock))))

(initialize-lock lock)
(acquire-lock lock) <graphic> #t
(acquire-lock lock) <graphic> #f
(release-lock lock)
(acquire-lock lock) <graphic> #t

Once the lock object has been created in this manner, it should be initialized via initialize-lock and is then ready to be acquired and released via acquire-lock and release-lock.

By virtue of implicit ftype subtyping (page 76), any structure that has an ftype-lock as its first field is also an ftype-lock, and the primitives of this section apply equally to such structures. For example:

(define-ftype counter
  (struct [lock ftype-lock] [n int]))
(define make-counter
  (lambda ()
    (let ([x (make-ftype-pointer counter
               (foreign-alloc (ftype-sizeof counter)))])
      (initialize-lock x)
      (ftype-set! counter (n) x 0)
      x)))
(define ctr (make-counter))

qualifies ctr as a lock object that can be acquired and released just like lock objects that do not also have the additional field, as illustrated below.

(define incr!
  (lambda (x)
    (let f ()
      (unless (acquire-lock x)
        (sleep (make-time 'time-duration 1000 0))
        (f)))
    (ftype-set! counter (n) x (+ (ftype-ref counter (n) x) 1))
    (release-lock x)))
(incr! ctr) 
(ftype-ref counter (n) ctr) <graphic> 1

Calling acquire-lock or release-lock directly on a subtype of lock, such as counter, is more convenient and more efficient than obtaining an ftype pointer to the lock via ftype-&ref and applying acquire-lock or release-lock to the resulting ftype pointer.

procedure: (initialize-lock lock)
returns: unspecified
libraries: (chezscheme)

initialize-lock is used to initialized lock and should be called before the first acquire or release operation.

See the examples in the description of ftype-lock above.

procedure: (acquire-lock lock)
returns: #t if the acquire operation succeeds and #f otherwise
libraries: (chezscheme)

The storage for lock should be properly allocated and initialized as described above in the entry for ftype-lock.

acquire-lock succeeds if lock has never before been acquired or if lock has been released since the last time it was acquired. Otherwise, acquire-lock fails.

See the examples in the description of ftype-lock above.

procedure: (release-lock lock)
returns: unspecified
libraries: (chezscheme)

The storage for lock should be properly allocated and initialized as described above in the entry for ftype-lock.

release-lock releases lock, allowing it to be acquired by a subsequent acquire-lock operation. It has no effect if lock has never been acquired or has been released since the last acquire.

release-lock can be used by any thread or process that has access to lock, not just by the thread or process that most recently acquired lock.

See the examples in the description of ftype-lock above.

Section 15.5. Thread Parameters

procedure: (make-thread-parameter object)
procedure: (make-thread-parameter object procedure)
returns: a new thread parameter
libraries: (chezscheme)

See Section 12.12 for a general discussion of parameters and the use of the optional second argument.

When a thread parameter is created, a separate location is set aside in each current and future thread to hold the value of the parameter's internal state variable. (This location may be eliminated by the storage manager when the parameter becomes inaccessible.) Changes to the thread parameter in one thread are not seen by any other thread.

When a new thread is created (see fork-thread), the current value (not location) of each thread parameter is inherited from the forking thread by the new thread. Similarly, when a thread created by some other means is activated for the first time (see Sactivate_thread in Section 4.8), the current value (not location) of each thread parameter is inherited from the main (original) thread by the new thread.

Most built-in parameters are thread parameters, but some are global. All are marked as global or thread where they are defined. There is no distinction between built-in global and thread parameters in the nonthreaded versions of the system.

Section 15.6. Buffered I/O

Chez Scheme buffers file I/O operations for efficiency, but buffered I/O is not thread safe. Two threads that write to or read from the same buffered port concurrently can corrupt the port, resulting in buffer overruns and, ultimately, invalid memory references.

Buffering on binary output ports can be disabled when opened with buffer-mode none. Buffering on input ports cannot be completely disabled, however, due to the need to support lookahead, and buffering on textual ports, even textual output ports, cannot be disabled completely because the transcoders that convert between characters and bytes sometimes require some lookahead.

Two threads should thus never read from or write to the same port concurrently, except in the special case of a binary output port opened buffer-mode none. Alternatives include appointing one thread to perform all I/O for a given port and providing a per-thread generic-port wrapper that forwards requests to the port only after acquiring a mutex.

The initial console and current input and output ports are thread-safe, as are transcript ports, so it is safe for multiple threads to print error and/or debugging messages to the console. The output may be interleaved, even within the same line, but the port will not become corrupted. Thread safety for these ports is accomplished at the high cost of acquiring a mutex for each I/O operation.

Section 15.7. Example: Bounded Queues

The following code, taken from the article "A Scheme for native threads [10]," implements a bounded queue using many of the thread-system features. A bounded queue has a fixed number of available slots. Attempting to enqueue when the queue is full causes the calling thread to block. Attempting to dequeue from an empty queue causes the calling thread to block.

(define-record-type bq
  (fields
    (immutable data)
    (mutable head)
    (mutable tail)
    (immutable mutex)
    (immutable ready)
    (immutable room))
  (protocol
    (lambda (new)
      (lambda (bound)
        (new (make-vector bound) 0 0 (make-mutex)
          (make-condition) (make-condition))))))

(define dequeue!
  (lambda (q)
    (with-mutex (bq-mutex q)
      (let loop ()
        (let ([head (bq-head q)])
          (cond
            [(= head (bq-tail q))
             (condition-wait (bq-ready q) (bq-mutex q))
             (loop)]
            [else
             (bq-head-set! q (incr q head))
             (condition-signal (bq-room q))
             (vector-ref (bq-data q) head)]))))))

(define enqueue!
  (lambda (item q)
    (with-mutex (bq-mutex q)
      (let loop ()
        (let* ([tail (bq-tail q)] [tail^ (incr q tail)])
          (cond
            [(= tail^ (bq-head q))
             (condition-wait (bq-room q) (bq-mutex q))
             (loop)]
            [else
             (vector-set! (bq-data q) tail item)
             (bq-tail-set! q tail^)
             (condition-signal (bq-ready q))]))))))

(define incr
  (lambda (q i)
    (modulo (+ i 1) (vector-length (bq-data q)))))

The code below demonstrates the use of the bounded queue abstraction with a set of threads that act as consumers and producers of the data in the queue.

(define job-queue)
(define die? #f)

(define make-job
  (let ([count 0])
    (define fib
      (lambda (n)
        (if (< n 2)
            n
            (+ (fib (- n 2)) (fib (- n 1))))))
    (lambda (n)
      (set! count (+ count 1))
      (printf "Adding job #~s = (lambda () (fib ~s))\n" count n)
      (cons count (lambda () (fib n))))))

(define make-producer
  (lambda (n)
    (rec producer
      (lambda ()
        (printf "producer ~s posting a job\n" n)
        (enqueue! (make-job (+ 20 (random 10))) job-queue)
        (if die?
            (printf "producer ~s dying\n" n)
            (producer))))))

(define make-consumer
  (lambda (n)
    (rec consumer
      (lambda ()
        (printf "consumer ~s looking for a job~%" n)
        (let ([job (dequeue! job-queue)])
          (if die?
              (printf "consumer ~s dying\n" n)
              (begin
                (printf "consumer ~s executing job #~s~%" n (car job))
                (printf "consumer ~s computed:  ~s~%" n ((cdr job)))
                (consumer))))))))

(define (bq-test np nc)
  (set! job-queue (make-bq (max nc np)))
  (do ([np np (- np 1)])
      ((<= np 0))
      (fork-thread (make-producer np)))
  (do ([nc nc (- nc 1)])
      ((<= nc 0))
      (fork-thread (make-consumer nc))))

Here are a possible first several lines of output from a sample run of the example program.

> (begin
    (bq-test 3 4)
    (system "sleep 3")
    (set! die? #t))
producer 3 posting a job
Adding job #1 = (lambda () (fib 29))
producer 3 posting a job
Adding job #2 = (lambda () (fib 26))
producer 3 posting a job
Adding job #3 = (lambda () (fib 22))
producer 3 posting a job
Adding job #4 = (lambda () (fib 21))
producer 2 posting a job
Adding job #5 = (lambda () (fib 29))
producer 1 posting a job
Adding job #6 = (lambda () (fib 29))
consumer 4 looking for a job
producer 3 posting a job
Adding job #7 = (lambda () (fib 24))
consumer 4 executing job #1
consumer 3 looking for a job
producer 2 posting a job
Adding job #8 = (lambda () (fib 26))
consumer 3 executing job #2
consumer 3 computed:  121393
consumer 3 looking for a job
producer 1 posting a job
Adding job #9 = (lambda () (fib 26))
...

Additional examples, including definitions of suspendable threads and threads that automatically terminate when they become inaccessible, are given in "A Scheme for native threads [10]."

R. Kent Dybvig / Chez Scheme Version 8 User's Guide
Copyright © 2009 R. Kent Dybvig
Revised October 2011 for Chez Scheme Version 8.4
Cadence Research Systems / www.scheme.com
Cover illustration © 2010 Jean-Pierre Hébert
ISBN: 978-0-966-71392-3
about this book / purchase this book in print form