Skip to content

Commit

Permalink
Document the blocking and yield API for various resources (#97)
Browse files Browse the repository at this point in the history
  • Loading branch information
Krastanov authored Aug 6, 2023
1 parent b8de238 commit c8595c6
Show file tree
Hide file tree
Showing 13 changed files with 168 additions and 82 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# News

## v1.2.0 - 2023-08-06

- Priorities can now be non-integer.
- Relax some of the previous deprecations, implement `Base.lock` and `Base.trylock`, and document the differences in blocking and yield-ness of Base and ConcurrentSim methods.

## v1.1.0 - 2023-08-02

- Start using `Base`'s API: `Base.unlock`, `Base.islocked`, `Base.isready`, `Base.put!`, `Base.take!`. Deprecate `put`, `release`. Moreover, consider using `Base.take!` instead of `Base.get` (which was not deprecated yet, as we decide which semantics to follow). Lastly, `Base.lock` and `Base.trylock` are **not** implement -- they are superficially similar to `request` and `tryrequest`, but have to be explicitly `@yield`-ed.
Expand Down
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ license = "MIT"
desc = "A discrete event process oriented simulation framework."
authors = ["Ben Lauwens and SimJulia and ConcurrentSim contributors"]
repo = "https://github.com/JuliaDynamics/ConcurrentSim.jl.git"
version = "1.1.0"
version = "1.2.0"

[deps]
DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8"
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,7 @@ A [detailed change log is kept](https://github.com/JuliaDynamics/ConcurrentSim.j

## Alternatives

`ConcurrentSim.jl` and `DiscreteEvents.jl` both provide for typical event-based simulations. `ConcurrentSim.jl` is built around coroutines (implemented in `ResumableFunctions.jl`), while `DiscreteEvents.jl` uses Julia's async primitives via `Channels`. If you are evaluating which library to you for your goals, `ConcurrentSim.jl` might be a good choice if you are used to python's SimPy, but otherwise you are advised to try a small demo project in each and do your own benchmarks. Do not hesitate to submit issues on Github with questions or suggestions or feature requests. We value hearing what your experience with this library (compared to other libraries) has been.
`ConcurrentSim.jl` and `DiscreteEvents.jl` both provide for typical event-based simulations.
`ConcurrentSim.jl` is built around coroutines (implemented in `ResumableFunctions.jl`), while `DiscreteEvents.jl` uses Julia's async primitives via `Channels`.
`DiscreteEvents.jl` has an explicit clock that "ticks" at a fixed finite resolution, while `ConcurrentSim.jl` uses coroutines to make arbitrary jumps in time.
If you are evaluating which library to you for your goals, `ConcurrentSim.jl` might be a good choice if you are used to python's SimPy, but otherwise you are advised to try a small demo project in each and do your own benchmarks. Do not hesitate to submit issues on Github with questions or suggestions or feature requests. We value hearing what your experience with this library (compared to other libraries) has been.
16 changes: 11 additions & 5 deletions docs/make.jl
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,17 @@ makedocs(
pages = [
"Home" => "index.md",
"Tutorial" => "tutorial.md",
"Topical Guides" => ["Basics" => "guides/basics.md",
"Environments" => "guides/environments.md",
"Events" => "guides/events.md",],
"Examples" => ["Ross" => "examples/ross.md", "Latency" =>
"examples/Latency.md"],
"Topical Guides" => [
"Basics" => "guides/basics.md",
"Environments" => "guides/environments.md",
"Events" => "guides/events.md",
"Resource API" => "guides/blockingandyielding.md",
],
"Examples" => [
"Ross" => "examples/ross.md",
"Latency" => "examples/Latency.md",
"Multi-server Queue" => "examples/mmc.md",
],
"API" => "api.md"
]
)
Expand Down
1 change: 1 addition & 0 deletions docs/src/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Private = false
```

```@docs
lock(res::Resource; priority=0)
unlock(res::Resource; priority=0)
take!(sto::Store, filter::Function=get_any_item; priority=0)
```
91 changes: 49 additions & 42 deletions docs/src/examples/mmc.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,14 @@ An [M/M/c queue](https://en.wikipedia.org/wiki/M/M/c_queue) is a basic queue wit

## Code

```julia
```jldoctest
using StableRNGs
using Distributions
using ConcurrentSim
using ResumableFunctions
#set simulation parameters
Random.seed!(8710) # set random number seed for reproducibility
rng = StableRNG(123)
num_customers = 10 # total number of customers generated
# set queue parameters
Expand All @@ -24,51 +29,53 @@ service_dist = Exponential(1 / mu) # service time distribution
println("Customer $id arrived: ", now(env))
@yield request(server) # customer starts service
println("Customer $id entered service: ", now(env))
@yield timeout(env, rand(d_s)) # server is busy
@yield timeout(env, rand(rng,d_s)) # server is busy
@yield unlock(server) # customer exits service
println("Customer $id exited service: ", now(env))
end
# setup and run simulation
sim = Simulation() # initialize simulation environment
server = Resource(sim, num_servers) # initialize servers
arrival_time = 0.0
for i = 1:num_customers # initialize customers
arrival_time += rand(arrival_dist)
@process customer(sim, server, i, arrival_time, service_dist)
function setup_and_run()
sim = Simulation() # initialize simulation environment
server = Resource(sim, num_servers) # initialize servers
arrival_time = 0.0
for i = 1:num_customers # initialize customers
arrival_time += rand(rng,arrival_dist)
@process customer(sim, server, i, arrival_time, service_dist)
end
run(sim) # run simulation
end
run(sim) # run simulation
setup_and_run()
## output
#
# Customer 1 arrived: 0.1229193244813443
# Customer 1 entered service: 0.1229193244813443
# Customer 2 arrived: 0.22607641035584877
# Customer 2 entered service: 0.22607641035584877
# Customer 3 arrived: 0.4570009029409502
# Customer 2 exited service: 1.7657345101378559
# Customer 3 entered service: 1.7657345101378559
# Customer 1 exited service: 2.154824561031012
# Customer 3 exited service: 2.2765287086137764
# Customer 4 arrived: 2.3661687470062995
# Customer 4 entered service: 2.3661687470062995
# Customer 5 arrived: 2.6110816119637885
# Customer 5 entered service: 2.6110816119637885
# Customer 5 exited service: 2.8017888690417583
# Customer 6 arrived: 3.019540357955037
# Customer 6 entered service: 3.019540357955037
# Customer 6 exited service: 3.351151832298383
# Customer 7 arrived: 3.5254699872847612
# Customer 7 entered service: 3.5254699872847612
# Customer 7 exited service: 4.261422043181396
# Customer 4 exited service: 4.602071952938201
# Customer 8 arrived: 7.27536704811686
# Customer 8 entered service: 7.27536704811686
# Customer 9 arrived: 7.491176033637809
# Customer 9 entered service: 7.491176033637809
# Customer 10 arrived: 8.39098457094977
# Customer 8 exited service: 8.683396356977969
# Customer 10 entered service: 8.683396356977969
# Customer 9 exited service: 8.7501656586875
# Customer 10 exited service: 9.049670951561666
# output
Customer 1 arrived: 0.14518451436852475
Customer 1 entered service: 0.14518451436852475
Customer 2 arrived: 0.5941831542903504
Customer 2 entered service: 0.5941831542903504
Customer 3 arrived: 1.5490648267819074
Customer 4 arrived: 1.6242796925312217
Customer 5 arrived: 1.6911000709069648
Customer 1 exited service: 2.200985520126681
Customer 3 entered service: 2.200985520126681
Customer 6 arrived: 2.2989039524296317
Customer 3 exited service: 3.5822120399442174
Customer 4 entered service: 3.5822120399442174
Customer 7 arrived: 4.377930221620456
Customer 8 arrived: 5.16494279700802
Customer 2 exited service: 5.900722829377648
Customer 5 entered service: 5.900722829377648
Customer 9 arrived: 7.0099944106308705
Customer 10 arrived: 7.828990220943469
Customer 5 exited service: 9.634196437885254
Customer 6 entered service: 9.634196437885254
Customer 4 exited service: 9.670688398447817
Customer 7 entered service: 9.670688398447817
Customer 7 exited service: 15.066978111608014
Customer 8 entered service: 15.066978111608014
Customer 8 exited service: 16.655548432659554
Customer 9 entered service: 16.655548432659554
Customer 6 exited service: 17.401833154870328
Customer 10 entered service: 17.401833154870328
Customer 9 exited service: 17.586065352135993
Customer 10 exited service: 18.690264775280085
```
51 changes: 51 additions & 0 deletions docs/src/guides/blockingandyielding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Blocking and Yielding Resource API

The goal of this page is to list the most common synchronization and resource management patterns used in `ConcurrentSim.jl` simulations and to briefly compare them to Julia's base capabilities for asynchronous and parallel programming.

There are many different approaches to discrete event simulation in particular and to asynchronous and parallel programming in general. This page assumes some rudimentary understanding of concurrency in programming. While not necessary, you are encouraged to explore the following resources for a more holistic understanding:

- "concurrency" vs "parallelism" - see [stackoverflow.com](https://stackoverflow.com/questions/1050222/what-is-the-difference-between-concurrency-and-parallelism) on the topic;
- "threads" vs "tasks": A task is the actual piece of work, a thread is the "runway" on which a task runs. You can have more tasks than threads and you can even have tasks that jump between threads - see Julia's [parallel programming documentation](https://docs.julialang.org/en/v1/manual/parallel-computing/) (in particular the [async](https://docs.julialang.org/en/v1/manual/asynchronous-programming/) and [multithreading](https://docs.julialang.org/en/v1/manual/multi-threading/) docs), and multiple Julia blog post on [multithreading](https://julialang.org/blog/2019/07/multithreading/) and [its misuses](https://julialang.org/blog/2023/07/PSA-dont-use-threadid/);
- "locks" used to guard (or synchronize) the access to a given resource: i.e. one threads locks an array while modifying it in order to ensure that another thread will not be modifying it at the same time. Julia's `Base` multithreading capabilities provide a `ReentrantLock`, together with a `lock`, `trylock`, `unlock`, and `islocked` API;
- "channels" used to organize concurrent tasks. Julia's `Base` multithreading capabilities provide `Channel`, together with `take!`, `put!`, `isready`;
- knowing of the ["red/blue-colored functions" metaphor](https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/) can be valuable as well as learning of "promises" and "futures".

Programming discrete event simulations can be very similar to async parallel programming, except for the fact that in the simulation the "time" is fictitious (and tracking it is a big part of the value proposition in the simulation software). On the other hand, in usual parallel programming the goal is simply to do as much work as possible in the shortest (actual) time. In that context, one possible use of discrete event simulations is to cheaply model and optimize various parallel implementations of actual expensive algorithms (whether numerical computer algorithms or the algorithms used to schedule a real factory or a fleet of trucks).

In particular, the `ConcurrentSim.jl` package uses the async "coroutines" model of parallel programing. `ConcurrentSim` uses the `ResumableFunctions.jl` package to build its coroutines, which uses the `@resumable` macro to mark a function as an "async" coroutine and the `@yield` macro to yield between coroutines.

!!! warning "Base Julia coroutines vs ConcurrentSim coroutines"
The `ConcurrentSim` and `ResumableFunctions` coroutines are currently incompatible with Julia's base coroutines (which based around `wait` and `fetch`). A separate coroutines implementation was necessary, because Julia's coroutines are designed for computationally heavy tasks and practical parallel algorithms, leading to significant overhead when they are used with extremely large numbers of computationally cheap tasks, as it is common in discrete event simulators. `ResumableFunctions`'s coroutines are single threaded but with drastically lower call overhead.
A future long-term goal of ours is to unify the API used by `ResumableFunctions` and base Julia, but this will not be achieved in the near term, hence the need for pages like this one.

Without further ado, here is the typical API used with:

- `ConcurrentSim.Resource` which is used to represent scarce resource that can be used by only up to a fixed number of tasks. If the limit is just one task (the default), this is very similar to `Base.ReentrantLock`. `Resource` is a special case of `Container` with an integer "resource counter".
- `ConcurrentSim.Store` which is used to represent a FILO stack.

```@raw html
<div style="width:120%;min-width:120%;">
```

||`Base` `ReentrantLock`|`Base` `Channel`|`ConcurrentSim` `Container`|`ConcurrentSim` `Resource`, i.e. `Container{Int}`|`ConcurrentSim` `Store`||
|---|:---|:---|:---|:---|:---|:---:|
|`put!`|||@yield|@yield|@yield|low-level "put an object in" API|
|`take!`||block|||@yield|the `Channel`-like API for `Store`|
|`lock`|block|||@yield||the `Lock`-like API for `Resource` (there is also `trylock`)|
|`unlock`|✔️|||@yield||the `Lock`-like API for `Resource`|
|`isready`||✔️|✔️|✔️|✔️|something is stored in the resource|
|`islocked`|✔️||✔️|✔️|✔️|the resource can not store anything more|

```@raw html
</div>
```

The table denotes which methods exist (✔️), are blocking (block), need to be explicitly yielded with `ResumableFunctions` (@yield), or are not applicable (❌).

As you can see `Resource` shares some properties with `ReentrantLock` and avails itself of the `lock`/`unlock`/`trylock` Base API. `Store` similarly shares some properties with `Channel` and shares the `put!`/`take!` Base API. Of note is that when the Base API would be blocking, the corresponding `ConcurrentSim` methods actually give coroutines that need to be `@yield`-ed.

`take!` and `unlock` are both implemented on top of the lower level `get`.

The `Base.lock` and `Base.unlock` are aliased to `ConcurrentSim.request` and `ConcurrentSim.release` respectively for semantic convenience when working with `Resource`.

`unlock(::Resource)` is instantaneous so the `@yield` is not strictly necessary. Similarly for `put!(::Store)` if the store has infinite capacity.
4 changes: 2 additions & 2 deletions src/ConcurrentSim.jl
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ module ConcurrentSim
export @resumable, @yield
export AbstractProcess, Simulation, run, now, active_process, StopSimulation
export Process, @process, interrupt
export Container, Resource, Store, put!, get, cancel, request, tryrequest
export Container, Resource, Store, put!, get, cancel, request, tryrequest, release
export nowDatetime

include("base.jl")
Expand All @@ -29,5 +29,5 @@ module ConcurrentSim
include("resources/containers.jl")
include("resources/stores.jl")
include("utils/time.jl")
include("deprecated.jl")
include("deprecated_aliased.jl")
end
4 changes: 0 additions & 4 deletions src/deprecated.jl

This file was deleted.

4 changes: 4 additions & 0 deletions src/deprecated_aliased.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Base.@deprecate put(args...; kwargs...) put!(args...; kwargs...)
const request = lock
const tryrequest = trylock
const release = unlock
23 changes: 11 additions & 12 deletions src/resources/containers.jl
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ A "Container" resource object, storing up to `capacity` units of a resource (of
There is a `Resource` alias for `Container{Int, Int}`.
`Resource()` with default capacity of `1` is very similar to a typical lock.
The [`request`](@ref) and [`unlock`](@ref) functions are a convenient way to interact with such a "lock",
The [`lock`](@ref) and [`unlock`](@ref) functions are a convenient way to interact with such a "lock",
in a way mostly compatible with other discrete event and concurrency frameworks.
The `request` and `release` aliases are also available for these two functions.
See [`Store`](@ref) for a more channel-like resource.
Expand Down Expand Up @@ -50,18 +51,18 @@ function put!(con::Container{N, T}, amount::N; priority=zero(T)) where {N<:Real,
end

"""
request(res::Container)
lock(res::Resource)
Locks the Container (or Resources) and return the lock event.
Locks the Resource and return the lock event.
If the capacity of the Container is greater than 1,
multiple requests can be made before blocking occurs.
"""
request(res::Resource; priority=0) = put!(res, 1; priority)
lock(res::Container; priority=0) = put!(res, 1; priority)

"""
tryrequest(res::Container)
trylock(res::Resource)
If the Container (or Resource) is not locked, locks it and return the lock event.
If the Resource is not locked, locks it and return the lock event.
Returns `false` if the Container is locked, similarly to the meaning of `trylock` for `Base.ReentrantLock`.
If the capacity of the Container is greater than 1,
Expand All @@ -80,9 +81,9 @@ julia> tryrequest(res)
false
```
"""
function tryrequest(res::Container; priority=0)
function trylock(res::Resource; priority=0)
islocked(res) && return false # TODO check priority
request(res; priority)
lock(res; priority)
end

function get(con::Container{N, T}, amount::N; priority=zero(T)) where {N<:Real, T<:Number}
Expand All @@ -94,9 +95,9 @@ function get(con::Container{N, T}, amount::N; priority=zero(T)) where {N<:Real,
end

"""
unlock(res::Container)
unlock(res::Resource)
Unlocks the Container and return the unlock event.
Unlocks the Resource and return the unlock event.
"""
unlock(res::Resource; priority::Number=0) = get(res, 1; priority=priority)

Expand Down Expand Up @@ -148,5 +149,3 @@ true
islocked(c::Container) = c.level==c.capacity

take!(::Container, args...) = error("There is no well defined `take!` for `Container`. Instead of attempting `take!` consider using `unlock(::Container)` or use a `Store` instead of a `Resource` or `Container`. Think of `Resource` and `Container` as locks and of `Store` as channels. They block only if empty (on taking) or full (on storing).")
lock(::Container) = error("Directly locking a `Container` is not implemented yet. Instead of attempting `lock`, consider using `@yield request(::Container)` from inside of a resumable function.")
trylock(::Container) = error("Directly locking a `Container` is not implemented yet. Instead of attempting `lock`, consider using `@yield request(::Container)` from inside of a resumable function.")
Loading

2 comments on commit c8595c6

@Krastanov
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JuliaRegistrator
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registration pull request created: JuliaRegistries/General/89153

After the above pull request is merged, it is recommended that a tag is created on this repository for the registered package version.

This will be done automatically if the Julia TagBot GitHub Action is installed, or can be done manually through the github interface, or via:

git tag -a v1.2.0 -m "<description of version>" c8595c6f89a84efc82c00826817e4cfdc516ceb6
git push origin v1.2.0

Please sign in to comment.