Concurrency
For concurrency Inko uses "lightweight processes", also known as green threads. Processes are isolated from each other and don't share memory, making race conditions impossible. Communication between processes is done by sending messages, which look like regular method calls. Messages are processed in FIFO order. Values passed with these messages have their ownership transferred to the receiving process.
Processes run concurrently, and Inko's scheduler takes care of balancing the workload across OS threads.
A process finishes when it has no more messages to process, and no references to the process exist.
Processes are cheap to spawn, with a single empty process needing less than 1 KiB of memory.
A process is defined using the syntax class async
:
class async Counter {}
The main process
Each program starts with a single process called "Main". The main process must be defined explicitly, and must define the async method "main":
class async Main {
fn async main {
}
}
When the main process finishes and no references to it exist, the program stops; even if other processes are still running.
Defining fields
A process can define zero or more fields:
class async Counter {
let @value: Int
}
While the field types don't have to be sendable, when creating an instance the assigned value must be sendable. For example:
class async List {
let @values: Array[Int]
}
To assign the @values
field when creating an instance of List
, we'd have to
assign it a unique value:
List { @values = recover [10, 20] }
Since a uni Array[Int]
can be moved into a Array[Int]
, this is valid. Had we
assigned it a regular array the program would not compile, because Array[Int]
isn't sendable.
Spawning processes
A process is spawned by creating an instance of its class. In the above example,
List { ... }
spawns the process for us, then gives us an owned value pointing
to the process. When spawning a process it doesn't start running right away,
instead it waits for its first message.
Defining messages
Messages are defined by defining methods with the async
keyword. The arguments
and return type of an async
method must be sendable (see Memory
management for more information).
async
methods can't specify return types, and thus can't return any values.
Channels can be used when one process needs the result of an async
method of
another process.
Here's how you'd define a message that just writes to STDOUT:
import std::stdio::STDOUT
class async Example {
fn async write(message: String) {
STDOUT.new.print(message)
}
}
Sending messages
Sending messages uses the same syntax as regular method calls:
class async Counter {
let @value: Int
fn async mut increment {
@value += 1
}
fn async value(output: Channel[Int]) {
output.send(@value)
}
}
class async Main {
fn async main {
let counter = Counter { @value = 0 }
let output = Channel.new(size: 1)
counter.increment
counter.value(output)
output.receive # => 1
}
}
Because async
methods can't return a value, we must pass in a Channel
to
send the output to and receive from.
Dropping processes
Processes are value types, making it easy to share references to a process with other processes. Internally processes use atomic reference counting to keep track of the number of incoming references. When the count reaches zero, the process is instructed to drop itself after it finishes running any remaining messages. This means that there may be some time between when the last reference to a process is dropped, and when the process itself is dropped.