Concurrency
Spawning Concurrent Tasks
V's model of concurrency is similar to Go's.
go foo() runs foo() concurrently in a lightweight thread managed by the V runtime.
spawn foo() runs foo() concurrently in a different thread:
If a spawned thread's handle is discarded, including inside a discarded array or struct,
V detaches the thread. Keep its handle and call wait() when the result or completion matters.
The detached thread releases an owned return value after its function finishes.
If the return value is a thread handle, it joins that thread; a returned closure releases
its captured context.
[!NOTE] Threads rely on the machine's CPU (number of cores/threads). Be aware that OS threads spawned with
spawnhave limitations in regard to concurrency, including resource overhead and scalability issues, and might affect performance in cases of high thread count.
Sometimes it is necessary to wait until a parallel thread has finished. This can
be done by assigning a handle to the started thread and calling the wait() method
to this handle later:
This approach can also be used to get a return value from a function that is run in a parallel thread. There is no need to modify the function itself to be able to call it concurrently.
Discarding a spawned thread's handle, including through dump(spawn ...), detaches the thread.
Keep the handle when you need to call .wait().
If there is a large number of tasks, it might be easier to manage them using an array of threads.
Additionally for threads that return the same type, calling wait()
on the thread array will return all computed values.
Arrays whose elements are aliases of thread handles support wait() as well,
including aliases of thread and thread T.
The elements must be handles themselves; arrays of pointers to handles cannot be joined.
When the spawned function returns ?T or !T, waiting on the thread array returns
?[]T or ![]T. That means the batch can be handled with or, ?, or ! the same
way as a single thread handle.
Channels
Channels are the preferred way to communicate between threads. They allow threads to exchange data
safely without requiring explicit locking. V's channels are similar to those in Go, enabling you
to push objects into a channel on one end and pop objects from the other.
Channels can be buffered or unbuffered, and you can use the select statement to monitor multiple
channels simultaneously.
Syntax and Usage
Channels are declared with the type chan objtype.
You can optionally specify a buffer length using the cap field:
Channels do not have to be declared as mut. The buffer length is not part of the type but
a field of the individual channel object. Channels can be passed to threads like normal
variables:
Buffered Channels
Buffered channels allow you to push multiple items without blocking, as long as the buffer is not full:
Closing Channels
A channel can be closed to indicate that no further objects can be pushed. Any attempt
to do so will then result in a runtime panic (with the exception of select and
try_push() - see below). Attempts to pop will return immediately if the
associated channel has been closed and the buffer is empty. This situation can be
handled using an or {} block (see Handling options/results).
An optional IError can be passed to close(err) so receive operations that use
or {} or ? can distinguish an error termination from a normal close.
Note: buffered channels can be closed while they have unread values in them. The buffered values can be retrieved, even after the closing:
... will produce:
>> loop 0..2 | x: 0 | ich.closed: false
>> loop 0..2 | x: 1 | ich.closed: false
>> final loop | x: 2 | ich.closed: true
>> final loop | x: 3 | ich.closed: true
>> final loop | x: 4 | ich.closed: true
Note: reading from the .closed field of the channel in the example,
is done just for clarity of illustration. The recommended way to pop values
from the channel is with: x := <-ich or { break } in a for loop,
which will cleanly break out of the loop, when the channel is closed and empty.
Avoid manually checking, whether the channel was closed or not, because that
can introduce data races, if you are not careful.
Channel Select
Assertion messages inside a select branch can use values declared or received in that branch, including arrays and structs converted to strings.
The select command allows monitoring several channels at the same time
without noticeable CPU load. It consists of a list of possible transfers and associated branches
of statements - similar to the match command:
select receive branches support fixed-array payloads, including fixed-array aliases, with GCC and
Clang.
The timeout branch is optional. If it is absent select waits for an unlimited amount of time.
It is also possible to proceed immediately if no channel is ready in the moment select is called
by adding an else { ... } branch. else and <timeout> are mutually exclusive.
In statement form, else also runs when every channel operation is unavailable because the
channels are already closed. To drain buffered values from a closed channel, use a plain
receive with or {} instead of select ... else.
The select command can be used as an expression of type bool
that becomes false if all channels are closed:
Special Channel Features
For special purposes there are some builtin fields and methods:
The try_push/pop() methods will return immediately with one of the results
.success, .not_ready or .closed - dependent on whether the object has been transferred or
the reason why not.
On an unbuffered channel, try_push() will return .not_ready unless a receiver is ready at
the same time.
Usage of these methods and fields in production is not recommended -
algorithms based on them are often subject to race conditions. Especially .len and
.closed should not be used to make decisions.
Use or branches, error propagation or select instead (see Syntax and Usage
and Channel Select above).
Shared Objects
Data can be exchanged between a thread and the calling thread via a shared variable.
Such variables should be created as shared and passed to the thread as such, too.
The underlying struct contains a hidden mutex that allows locking concurrent access
using rlock for read-only and lock for read/write access.
Note: Shared variables must be structs, arrays or maps.
Example of Shared Objects
Thread-safe maps
A single operation on a shared map does not need a lock/rlock block: it locks
the map by itself, for the duration of that one operation. So a shared map can be used
as a thread-safe map:
Statements that change the map (m[k] = v, m[k] += v, m[k]++, m[k] << v,
m.delete(k) and m.clear()) take a lock. The whole statement runs with the map
locked, so m[k] = m[k] * 2 is atomic too. Reads (m[k], m[k] or { ... }, k in m,
m.len, m.keys(), m.values() and m.clone()) take an rlock. This works for
shared variables, parameters, globals and struct fields.
Operations that must happen together still need an explicit block, and so do iterating
over the map, if v := m[k] { and statements that use two shared maps:
Difference Between Channels and Shared Objects
Purpose:
- Channels: Used for message passing between threads, ensuring safe communication.
- Shared objects: Used for direct data sharing and modification between threads.
Synchronization:
- Channels: Implicit (via channel operations)
- Shared objects: Explicit (via
rlock/lockblocks)
Race Detector
A data race happens when two threads access the same memory at the same time, and at least
one of the accesses is a write, without a channel, a lock/rlock block, a sync primitive
or an atomic operation ordering them. Data races are hard to find: they depend on timing, and
the program usually works until it does not.
Like Go, V has a race detector built in. Build or run a program, or its tests, with -race:
v -race run main.v
v -race test .
Consider this program, where two threads increment one counter without synchronization:
v -race run main.v reports the race with the V source positions of both accesses, of the
allocation, and of the spawn calls that started the threads:
==================
WARNING: ThreadSanitizer: data race (pid=55780)
Write of size 8 at 0x000109800380 by thread T2:
#0 inc main.v:8
#1 inc_args_thread_wrapper src.c:2438
Previous write of size 8 at 0x000109800380 by thread T1:
#0 inc main.v:8
#1 inc_args_thread_wrapper src.c:2438
Location is heap block of size 8 at 0x000109800380 allocated by main thread:
#0 malloc <null>
#1 v_malloc allocation.c.v:96
#2 memdup allocation.c.v:503
#3 main main.v:13
Thread T2 (tid=19154470, running) created by main thread at:
#0 pthread_create <null>
#1 __v_thread_spawn src.c:672
#2 main main.v:15
...
SUMMARY: ThreadSanitizer: data race main.v:8 in inc
==================
2000
ThreadSanitizer: reported 1 warnings
Making n an atomic int, putting it in a shared object, or guarding it with a
sync.Mutex removes the race.
The race detector finds the races that happen while the program runs; it cannot find races in code that does not run. So it is most useful with tests and realistic workloads. A program that reported races exits with status 66, so a test that races fails.
How it works: -race compiles the program with ThreadSanitizer (-fsanitize=thread), the
race detection runtime that Go's race detector uses too. Like Go's runtime, V's channels,
sync types and closure allocator tell it the happens-before relations that the language
guarantees, instead of those of their implementation. V passes the race detector test suite
of Go (vlib/v/slow_tests/race). -race needs clang or gcc with the ThreadSanitizer
runtime (on some Linux distributions, the libtsan package for gcc), and uses clang when it
is installed: gcc does not instrument copies of whole struct values, like strings and arrays
passed to functions, so it misses races on them. It is supported on linux (amd64, arm64,
ppc64le, s390x, loongarch64, riscv64), macos (amd64, arm64), freebsd/amd64 and netbsd/amd64.
Race builds:
- do not use a garbage collector, like
-gc none, and cannot use-prealloc: ThreadSanitizer has to see every allocation and free of heap memory, which a garbage collector or an arena allocator hides from it; - typically run 2-20x slower and use 5-10x more memory, like in Go (with
-prod, a function that was inlined into its caller is reported as the caller); - define
race, so code can check for them with$if race ? {}, and_d_race.vfiles are compiled in them. That define is reserved:-d racewithout-raceis an error.
The VRACE environment variable passes options to the race detector, in the same format as
Go's GORACE. For example, VRACE="halt_on_error=1" stops the program at the first race,
VRACE="log_path=/tmp/race" writes the reports to /tmp/race.<pid> instead of stderr, and
VRACE="exitcode=1" changes the exit status. TSAN_OPTIONS takes the same options, and
overrides VRACE.
On some Linux kernels, older ThreadSanitizer runtimes stop with
FATAL: ThreadSanitizer: unexpected memory mapping. Run the program with address space
randomization reduced (setarch $(uname -m) -R ./program, or
sudo sysctl vm.mmap_rnd_bits=28), or use a newer compiler.