Shared data#

Under upstream Perl 5.42, sharing data between ithreads is explicit: variables are private by default, and become visible across threads only when the :shared attribute is applied or a value is passed through a thread-safe primitive. This chapter walks through the full upstream shared-data surface — threads::shared, lock, semaphores, queues, condition variables — and flags pperl’s status at each section.

pperl status at a glance#

None of the sharing primitives on this page do anything at runtime under pperl:

  • use threads::shared fails at compile time — the module is not shipped.

  • :shared is parsed but has no underlying interpreter to share to.

  • lock is a silent no-op, as documented on its reference page.

  • Thread::Queue and Thread::Semaphore are not available.

Reading this page is still worth your time if you maintain upstream Perl code or need to read existing ithreaded programs. For new concurrency on pperl, skip to Alternatives.

Shared and unshared variables#

By default, every Perl variable in an ithread is private. When you spawn a thread, the child starts with a copy of the parent’s data and goes its own way:

use threads;

my $private = 1;
threads->create(sub { $private++ })->join;
print $private, "\n";   # 1 — parent untouched

To cross the thread boundary, mark the variable :shared and use threads::shared:

use threads;
use threads::shared;

my $shared :shared = 1;
threads->create(sub { $shared++ })->join;
print $shared, "\n";    # 2 — write survived into parent

For an aggregate, every element becomes shared automatically:

my @queue :shared;
my %counts :shared;

Only simple scalars and references to other shared variables may be stored into a shared aggregate. Storing a reference to an unshared variable dies — upstream refuses the assignment to protect the sharing boundary.

The lock built-in#

lock places an advisory mutex on a shared datum for the rest of the enclosing block:

use threads;
use threads::shared;

my $total :shared = 0;

sub calc {
    while (my $result = compute_chunk()) {
        lock $total;            # block until mutex available
        $total += $result;
    }                            # mutex released here
}

Properties worth remembering:

  • Advisory only. lock blocks other callers of lock on the same datum. A thread that reads or writes $total without calling lock is not serialised.

  • Block-scoped. Release happens at block exit, not at the end of the lock statement. There is no unlock.

  • Recursive per thread. The same thread may re-acquire a lock it already holds; other threads still block.

  • Works on aggregates. lock @queue, lock %h, lock &sub are all valid.

Under pperl, lock accepts its argument and returns — no mutex, no blocking. See the lock reference page for the full behaviour and the Differences from upstream note.

Races#

The canonical race:

use threads;
use threads::shared;

my $x :shared = 1;
my $t1 = threads->create(sub { my $foo = $x; $x = $foo + 1 });
my $t2 = threads->create(sub { my $bar = $x; $x = $bar + 1 });
$t1->join; $t2->join;
print $x, "\n";         # 2 or 3 — depends on scheduling

Two threads read $x, compute a new value, and write back. If both reads land before either write, both writes store the same value and one increment is lost. Even $x++ on a shared scalar is not atomic.

The fix is to hold the lock across the read-modify-write:

{
    lock $x;
    $x++;
}

Under pperl this problem does not arise because auto-parallelisation does not expose shared mutable state to user code. The analyser declines to parallelise anything that writes a non-reduction global. See Parallel Execution, section How reduction detection works.

Deadlocks#

Two threads, two locks, acquired in opposite order:

my $x :shared = 4;
my $y :shared = 'foo';

threads->create(sub { lock $x; sleep 1; lock $y })->join;
threads->create(sub { lock $y; sleep 1; lock $x })->join;
# Both threads wait forever.

The standard defences:

  • Fixed lock order. Every thread that needs multiple locks acquires them in the same global order. Always $x before $y.

  • Short critical sections. Hold a lock only for the work that must be serialised, not for anything else.

  • Fewer locks. Protect the whole structure with one lock rather than one per field.

Queues — the preferred communication channel#

Thread::Queue is a thread-safe FIFO. Producer threads enqueue, consumer threads dequeue, and the queue handles all synchronisation internally:

use threads;
use Thread::Queue;

my $q = Thread::Queue->new;
my $worker = threads->create(sub {
    while (defined(my $item = $q->dequeue)) {
        handle($item);
    }
});

$q->enqueue($_) for @tasks;
$q->enqueue(undef);     # sentinel: tell worker to stop
$worker->join;

dequeue blocks when the queue is empty. Enqueueing undef (or any agreed sentinel) is the conventional shutdown signal.

Queues replace most explicit lock / cond_wait patterns in modern Perl threaded code. They are harder to get wrong.

Under pperl the equivalent shape is a straight Perl array combined with the parallel map or grep built-ins — see Alternatives.

Semaphores#

Thread::Semaphore is a counter with blocking down and up:

use threads;
use Thread::Semaphore;

my $sem = Thread::Semaphore->new(4);    # 4 concurrent I/O slots

sub do_io {
    $sem->down;
    # ... perform I/O; at most 4 threads here at once ...
    $sem->up;
}

With a starting count of 1, a semaphore behaves like a mutex but must be released explicitly — unlike lock, which is scope-released. Starting counts above 1 model pools of identical resources: a quota of concurrent open files, a number of allowed simultaneous HTTP calls, and so on.

Condition variables#

cond_wait and cond_signal — paired with lock on a shared scalar — let one thread wait for another to signal a state change. They resemble POSIX pthread_cond_wait / pthread_cond_signal. For the overwhelming majority of cases a queue does the same job more simply; reach for condition variables only when a queue does not fit the data shape.

Process-scope side effects#

Even with nothing shared at the Perl level, threads share the OS process. Anything that changes process state changes it for every thread:

  • chdir — current working directory

  • chroot — root directory, unrecoverable

  • umask — default file mode mask

  • setuid / setgid — effective user / group

  • Signal dispositions — there is no per-thread signal mask in Perl

This is a classic source of “mysterious” cross-thread effects in otherwise shared-nothing code. Both upstream and pperl inherit this from the OS.

Differences from upstream#

pperl does not ship threads::shared, Thread::Queue, or Thread::Semaphore. The :shared attribute, cond_wait, and cond_signal are not recognised. lock compiles but is a no-op; this matches upstream’s behaviour under a build without thread support.

pperl exposes parallelism at a different layer — the JIT compiler dispatches eligible loop bodies across a Rayon thread pool, with automatic analysis of counter variables, reduction accumulators, and side effects. No user-visible synchronisation primitives are needed because the analyser refuses to parallelise any loop whose body is not trivially independent. See Parallel Execution for the details and Alternatives for the pperl-native patterns.

See also#

  • lock — reference page for the only shared-data primitive with a pperl compile-time parse

  • ithreads basics — spawning, joining, detaching

  • Alternatives — the pperl-native concurrency patterns that replace the primitives above

  • Parallel Execution — how auto-parallelisation handles reductions and detects side effects

  • fork — process-level concurrency with no shared memory