Repository navigation
Modern Goroutines Tutorial
Home › Goroutines and Channels › Goroutines Tutorial
Who this is for: a tiko programmer who has never written a thread. No prior knowledge of concurrency, threads, locks or message passing is assumed. By the end you will be able to write a worker pool, a pipeline, a cancellable background scan, and a Win32 app that stays responsive — and you will know which of those you shouldn't write.
What you need:
- A tiko toolchain:
toolchains\tiko-windows\tiko.exeon Windows,toolchains/tiko-linux/bin/tikoon Linux,toolchains/tiko-macos/bin/tikoon macOS. - The
-mtflag on every build.std/tasksrefuses to compile without it (TKO0309, its#error) rather than link a single-threaded runtime and misbehave quietly.
toolchains\tiko-windows\tiko.exe -mt example.tikoThe reference — every rule, every table, every edge case — is
goroutines.txt in this folder. This document teaches; that
one specifies. When you finish here, read that.
Every program in this tutorial was compiled and run. The outputs shown are real program output, not illustrations. Under tiko, on windows-x86_64:
- The output shown: every program, ex01–ex07 and ex09–ex19, up to what varies from run to run: the interleaving of concurrent lines, ex01's elapsed seconds, which worker takes which job in ex07, ex10's hit count before its deadline, and ex15's lost-update count, which is random by design.
- ex08 is two procedures rather than a program; with the
#importandusing tasksit needs, it compiles.
tiko reports its compiler messages with TKOnnnn ids: a byref parameter at
a spawn site is TKO0812, and a RecvChan or SendChan in a select channel
case is TKO0809, which names the std/tasks generic that could not be
instantiated.
| # | Chapter | What you learn |
|---|---|---|
| 0 | Why bother | The three problems concurrency actually solves |
| 1 | Your first goroutine |
spawn, and what it really costs |
| 2 | Getting an answer back |
Channel, Send, Receive
|
| 3 | Buffer size and backpressure | Rendezvous vs buffered |
| 4 | Closing and draining |
Close, for each, end-of-stream |
| 5 | Waiting for everyone |
WaitGroup, and the abandonment trap |
| 6 | A worker pool | Fan-out, fan-in, who closes what |
| 7 | One-way channels |
SendChan / RecvChan
|
| 8 | Waiting on several things | select channel |
| 9 | Deadlines |
ReceiveFor, SendFor
|
| 10 | Cancellation |
Context, WithCancel, WithTimeout
|
| 11 | Errors | Why exceptions don't work here |
| 12 | Pipelines | Multi-stage streaming |
| 13 | Limiting concurrency | Semaphores and rate limits |
| 14 | Shared state | The data race, demonstrated |
| 15 | Desktop apps | The one rule that matters in Win32 |
| 16 | Pitfalls | The checklist to re-read |
| — | Reference tables | Every operation, every state |
| — | Glossary | Every term used above |
Almost every program that "needs threads" needs them for one of exactly three reasons. It is worth knowing which one you have, because the answer changes what you write.
1. Waiting. Your program asks for something slow — a web page, a database row, a file on a network share — and then does nothing until the answer comes back. A CPU that is waiting on a network is not working. If you have five things to fetch, fetching them one at a time takes five times as long as fetching them together, and none of that extra time is your program computing anything.
2. Cores. Your machine has eight cores. A single-threaded program uses one. Hashing ten thousand files, resizing a folder of images, parsing a large log — these are jobs where eight cores really do finish in something close to an eighth of the time.
3. Responsiveness. A Win32 program has a message loop. While your code is running, that loop is not running, so the window does not repaint and does not respond to clicks. Windows notices after a few seconds and paints "(Not Responding)" over your title bar. Anything that takes longer than about 100 ms has to happen somewhere other than the UI thread.
These two words get used interchangeably and they are not the same thing.
- Concurrency is structure: your program is written as several independent activities that make progress without waiting for each other.
- Parallelism is execution: several things literally running at the same instant, on different cores.
Concurrency is what you write. Parallelism is what you sometimes get. A concurrent program on a single-core machine still wins problem 1 (waiting) and problem 3 (responsiveness), because those aren't about compute at all.
tiko has built-in threads, and their entire surface is four opaque
pointers: ThreadCreate returns an any ptr, and so do MutexCreate and
CondCreate. There is no way to return a value from a thread, no message
passing, no timed wait anywhere, and no way to wake one specific waiter. So
every concurrent program written on them invents the same shared-global-plus-mutex
protocol from scratch, and nothing checks any of it.
Goroutines and channels replace that with two ideas:
-
spawn— run this procedure concurrently. -
Channel— a typed queue that safely carries values between concurrent activities, and blocks when it should.
That is the whole model. Everything else in this tutorial is a pattern built out of those two things.
Three pages to fetch. Each takes 300 ms, and every millisecond of that is spent waiting on a server.
'' ex01 -- the sequential baseline
sub FetchPage( byval id as int32 )
sleep 300, 1 '' pretend: a network round-trip
print "fetched page " & id
end sub
dim as float64 t0 = timer
for i as int32 = 1 to 3
FetchPage( i )
next
print using "sequential: #.## seconds"; timer - t0fetched page 1
fetched page 2
fetched page 3
sequential: 0.93 seconds
sleep 300, 1sleeps 300 ms. The, 1means "do not wake early if the user presses a key" — always write it in a worker.
Nearly a second, and the CPU was idle for all of it.
One new word: spawn.
'' ex02 -- the same three fetches, concurrently
#import "std/tasks"
using tasks
sub FetchPage( byval id as int32 )
sleep 300, 1
print "fetched page " & id
end sub
dim as float64 t0 = timer
for i as int32 = 1 to 3
spawn FetchPage( i )
next
sleep 1000, 1 '' PLACEHOLDER -- chapter 5 removes this
print using "concurrent: #.## seconds"; timer - t0fetched page 3
fetched page 1
fetched page 2
concurrent: 1.00 seconds
Three 300 ms waits overlapped into one. (The measured second is our crude
sleep 1000 placeholder, not the work — chapter 5 replaces it with something
that waits exactly as long as necessary.)
3, 1, 2. Not 1, 2, 3.
Concurrent work completes in an order nobody promised. Run it again and you may get a different order. This is not a bug and it will not be fixed by trying harder; it is the defining property of concurrent code. If your program needs an order, you must impose one — chapters 4 and 6 show how. If it doesn't, don't assume one.
spawn FetchPage( i )The arguments are evaluated here, at the spawn site, and copied into the
goroutine. That is the whole reason spawn is a statement in the language and
not a library call, and it makes the obvious loop do the obvious thing:
for i as int32 = 1 to 10
spawn Work( i ) '' each goroutine gets ITS OWN copy of i
nextA design based on closures would have all ten goroutines share one i, and most
of them would see 11. (tiko's capturing lambdas build their closure on the
stack, so handing one to another thread is a use-after-free that compiles
cleanly. This is why lambdas cannot be spawned.)
A goroutine here is a work item on a thread pool. It is not Go's goroutine,
and the difference is not a matter of effort. Go can multiplex millions of
goroutines onto a handful of OS threads because the Go runtime owns every
blocking operation in the language. tiko programs call Win32 and libc
directly; a fibre that called Sleep or ReadFile would freeze every other
fibre sharing its carrier thread, silently.
The practical consequences:
- Spawning is a queue push, not a thread creation. It is cheap. Thousands of goroutines are fine. Millions are not.
- A goroutine blocked in a syscall holds an OS thread. The pool grows when that happens, capped at 256 workers.
-
A goroutine runs on an arbitrary worker thread. Never touch an
HWND, a GDI object or a COM apartment from one — see chapter 15. - Nothing joins goroutines. Work still running at program exit is abandoned, silently — see chapter 5.
sub |
yes |
function |
yes — the result is discarded, as in Go |
| overloaded procedure | yes, resolved from the argument types |
obj.Method( a ) |
no — the compiler stops with an internal error (TKO0994) |
a procedure inside a namespace
|
no — the compiler stops with an internal error (TKO0994) |
| a procedure pointer or lambda | no — TKO0943 |
Two more rules the compiler enforces:
-
A
byrefparameter is a compile error at a spawn site. Copying would defeat thebyref; honouring it would hand the goroutine a reference into a stack frame that is about to die. Pass a pointer if sharing is what you mean.error 358: SPAWN cannot pass a BYREF parameter, at parameter 1 (n) of WORK() -
Every argument must be written out. A default argument cannot be omitted at a spawn site.
A goroutine returns nothing. Not "returns void" — there is no mechanism at all, and Go has none either. The channel is the return path.
A Channel( of T ) is a typed queue with two fundamental operations:
-
Send( v )— put a value in; blocks if there is no room. -
Receive( out )— take a value out; blocks until there is one.
"Blocks" means the goroutine stops there and the worker thread goes off and runs something else. That is the point: blocking on a channel is cheap and correct, unlike blocking on a UI thread.
'' ex03 -- getting an answer back
#import "std/tasks"
using tasks
sub CountWords( byval text_ as ustring, byval reply as Channel( of int32 ) )
dim as int32 n = 0
for i as int32 = 0 to len( text_ ) - 1
if( text_[i] = asc( " " ) ) then n += 1
next
reply.Send( n + 1 )
end sub
dim reply as Channel( of int32 ) = Channel( of int32 )( 1 )
spawn CountWords( "the quick brown fox jumps", reply )
dim as int32 words
reply.Receive( words ) '' blocks until the answer arrives
print "words: " & wordswords: 5
Compare that with what you would write without a channel: a shared global for
the result, a shared boolean for "is it ready", a mutex, a condition variable,
and a predicate loop — around twenty lines and three globals, none of them
checked by anything. Above, the type of the answer is written once, in the type
of the channel, and a second worker returning a ustring is a second channel and
not a second global.
dim a as Channel( of int32 ) = Channel( of int32 )( 8 )
dim b as Channel( of int32 ) = a '' NOT a copy of the queueA Channel is a refcounted handle. Handing a copy to each of four workers
gives four handles on one queue. This is deliberately unlike containers.Array,
whose copy is deep. It is what makes spawn Worker( jobs ) work at all, since
spawn copies its arguments.
The destructor does not close the channel. Closing is an explicit statement about the stream (chapter 4), never a side effect of a variable going out of scope.
tiko identifiers are case-insensitive and the language has a large keyword
list. name, line, close, open, error, time, date, left, right
and friends are all statements. In this tutorial you will see text_, out_,
in_, line_, final_, left_ — the trailing underscore is a house habit for
exactly this reason. Get it wrong and the error can be baffling:
error 59: Illegal specification, at parameter 1 (name) of Greeter()
The number you pass to the constructor is the buffer capacity, and choosing it is a real design decision, not a tuning knob.
dim ch as Channel( of int32 ) = Channel( of int32 )( 16 ) '' buffered: holds 16
dim rv as Channel( of int32 ) = Channel( of int32 )( 0 ) '' rendezvous
dim nl as Channel( of int32 ) '' NIL -- see belowSend on a capacity-0 channel does not return until a receiver has taken the
value. It is a handoff and a synchronisation point: after Send returns, you
know the other side got it. Use it when the handoff itself is the meaning.
Send blocks only when the buffer is full. This is backpressure: a producer
that outruns its consumer is forced to slow down to the consumer's rate.
'' ex04 -- backpressure: a fast producer against a slow consumer
#import "std/tasks"
using tasks
sub FastProducer( byval out_ as Channel( of int32 ) )
for i as int32 = 1 to 6
out_.Send( i )
print " produced " & i & " (buffer now holds " & out_.Count( ) & ")"
next
out_.Close( )
end sub
dim q as Channel( of int32 ) = Channel( of int32 )( 2 ) '' room for two
spawn FastProducer( q )
dim as int32 v
while q.Receive( v )
sleep 120, 1 '' the slow consumer
print "consumed " & v
wend produced 1 (buffer now holds 0)
produced 2 (buffer now holds 1)
produced 3 (buffer now holds 2)
consumed 1
produced 4 (buffer now holds 2)
consumed 2
produced 5 (buffer now holds 2)
consumed 3
produced 6 (buffer now holds 2)
consumed 4
consumed 5
consumed 6
Read the shape of that: the producer sprints to fill the buffer, then locks step with the consumer — one produced for each one consumed — because the buffer stays full. The producer's speed is now the consumer's speed. That is the mechanism that keeps a fast producer from eating all your memory.
There is no unbounded capacity, on purpose. An unbounded channel converts a slow consumer into an out-of-memory kill, hours later, far from the cause.
| Capacity | Use when |
|---|---|
0 |
The handoff is a synchronisation point; you want the sender to know it landed. |
1 |
A single result, or a "latest value" slot. |
| small (4–64) | Smoothing out jitter between a producer and a consumer of roughly equal speed. |
| = number of jobs | You want to enqueue all work up front and never block the filler (chapter 6). |
A default-constructed Channel — dim nl as Channel( of int32 ) with no
constructor argument — is nil, and Send and Receive on it block
forever.
That looks like a defect until chapter 8, where assigning a nil channel turns out
to be how you disable a case in a select. IsNil( ) exists so a program can
assert its own initialisation, because "my program hangs and prints nothing" is
the symptom of forgetting to construct one.
A producer that has finished says so by calling Close( ).
Close means exactly one thing: "no more values will be sent." It does not
discard buffered items, it does not free anything, and it is not a destructor. It
wakes every blocked waiter.
'' ex05 -- close, and the two ways to drain
#import "std/tasks"
using tasks
sub ReadLines( byval out_ as Channel( of ustring ) )
dim as ustring lines_(0 to 2) = { "alpha", "beta", "gamma" }
for i as int32 = 0 to 2
out_.Send( lines_(i) )
next
out_.Close( ) '' "no more values will be sent"
end sub
sub Main( )
dim src as Channel( of ustring ) = Channel( of ustring )( 4 )
spawn ReadLines( src )
for each s in src '' drains until closed AND empty
print "for each: " & s
next
dim src2 as Channel( of ustring ) = Channel( of ustring )( 4 )
spawn ReadLines( src2 )
dim as ustring s2
while src2.Receive( s2 ) '' FALSE = end of stream
print "while : " & s2
wend
print "done"
end sub
Main( )for each: alpha
for each: beta
for each: gamma
while : alpha
while : beta
while : gamma
done
Receive returns FALSE only when the channel is closed and drained. That
is the end-of-stream signal, not a failure, and it is what makes both loops
above terminate. Note the ordering: a closed channel with items still buffered
keeps returning TRUE and handing them over. Nothing is lost by closing.
When Receive returns FALSE, the output variable is not modified. Remember
that — chapter 8 turns it into a technique.
for each v in ch is the same loop with less typing. Prefer it when you want
every value and nothing else.
Two operations raise a runtime error (error 1, illegal function call) rather
than returning FALSE:
-
SendafterClose -
Closetwice
Both mean the same thing: two parties disagree about who owns the producer role. That is a structural bug in your program, not a runtime condition you should handle, and it deserves to be loud. Hence the two rules:
- The producer closes. Never the consumer.
- Exactly one producer closes. With several producers on one channel, none of them may close it — chapter 6 shows who does.
'' ex19 -- work still running at program exit is ABANDONED, silently
#import "std/tasks"
using tasks
sub SaveDraft( byval id as int32 )
sleep 300, 1
print "draft " & id & " saved" '' you may never see this line
end sub
for i as int32 = 1 to 3
spawn SaveDraft( i )
next
print "main is done"main is done
Three drafts, none saved, no error, no warning, exit code 0. Nothing joins goroutines. When the main program ends, everything still running is dropped where it stands.
The sleep 1000 in chapter 1 was papering over this: guess too low and you lose
work, guess too high and you waste time, and either way the guess is wrong on a
different machine. Never ship it.
A WaitGroup is a counter with a queue attached. Add raises it, Done lowers
it, and Wait blocks until it reaches zero.
'' ex06 -- wait for a fan-out to finish, with a WaitGroup
#import "std/tasks"
using tasks
sub UploadFile( byval id as int32, byval wg as WaitGroup ptr )
sleep 100 + id * 40, 1
print "uploaded file " & id
wg->Done( ) '' always, on every exit path
end sub
sub Main( )
dim wg as WaitGroup
for i as int32 = 1 to 5
wg.Add( ) '' BEFORE the spawn, never inside it
spawn UploadFile( i, @wg )
next
wg.Wait( )
print "all 5 uploads finished"
end sub
Main( )uploaded file 1
uploaded file 2
uploaded file 3
uploaded file 4
uploaded file 5
all 5 uploads finished
Three rules, all of which bite:
-
Addbefore thespawn, always. CallingAddfrom inside the goroutine racesWait: if the main thread reachesWaitbefore the goroutine gets scheduled, the counter is still zero andWaitreturns immediately. -
Doneon every exit path. A goroutine that returns early without callingDoneleavesWaitblocked forever. If the body is complicated, usedefer. -
A
WaitGroupis non-copyable, so pass a pointer. A copy would be a second counter, and one of them would never reach zero.spawncopies its arguments, andbyrefis a compile error at a spawn site, so@wg— a pointer — is the way in. That is whyWorkertakes aWaitGroup ptrand callswg->Done( ).
Everything every counted goroutine did before its Done is visible after
Wait returns, with no other synchronisation. This matters more than it sounds:
without such a guarantee, a value written by a worker might still be sitting in
another core's cache when you read it.
Also available: WaitFor( ms ) returns FALSE on timeout instead of waiting
forever, and Count( ) reports the current value.
Add below zero, Done below zero, and Add from zero while a Wait is in
progress all raise. The destructor does not wait; destroying a WaitGroup
with waiters still parked on it raises rather than leaving them parked forever.
This is the shape you will write most often: N workers, one shared queue of jobs, one shared stream of results. Hashing every file in a folder, resizing a folder of images, checking a list of URLs — all the same program.
Two ideas make it work:
-
Fan-out. Several goroutines
Receivefrom the same channel. A channel is a FIFO queue and each item goes to exactly one receiver, so the workers share the work automatically, and a worker that finishes early simply takes the next job. You get load balancing for free, with no scheduling code. -
Fan-in. Several goroutines
Sendto the same results channel. Safe by construction; the only question is who closes it.
'' ex07 -- a worker pool: fan-out to N workers, fan-in to one result stream
#import "std/tasks"
using tasks
sub Worker( byval id as int32, _
byval jobs as RecvChan( of int32 ), _
byval results as SendChan( of ustring ), _
byval wg as WaitGroup ptr )
for each n in jobs '' every worker pulls from the SAME queue
sleep 60, 1 '' pretend: hash a file
results.Send( "worker " & id & " finished job " & n )
next
wg->Done( )
end sub
'' Somebody has to close 'results', and it cannot be a worker: the first one
'' to finish would close it under the others. This goroutine waits for all
'' of them and then closes -- the only correct owner of that Close.
sub CloseWhenDone( byval results as Channel( of ustring ), byval wg as WaitGroup ptr )
wg->Wait( )
results.Close( )
end sub
sub Main( )
const NJOBS = 12
const NWORKER = 4
dim jobs as Channel( of int32 ) = Channel( of int32 )( NJOBS )
dim results as Channel( of ustring ) = Channel( of ustring )( NJOBS )
dim wg as WaitGroup
for i as int32 = 1 to NJOBS
jobs.Send( i )
next
jobs.Close( ) '' workers' 'for each' ends when it drains
for i as int32 = 1 to NWORKER
wg.Add( )
spawn Worker( i, jobs, results, @wg )
next
spawn CloseWhenDone( results, @wg )
dim as int32 got = 0
for each r in results
got += 1
print r
next
print "collected " & got & " results"
end sub
Main( )worker 1 finished job 1
worker 3 finished job 3
worker 2 finished job 2
worker 4 finished job 4
worker 2 finished job 7
worker 4 finished job 8
worker 1 finished job 5
worker 3 finished job 6
worker 1 finished job 11
worker 3 finished job 12
worker 2 finished job 9
worker 4 finished job 10
collected 12 results
The one genuinely non-obvious piece is CloseWhenDone. Trace why the naive
alternatives fail:
-
A worker closes
resultswhen it finishes. The first worker to finish closes the channel out from under the other three, and their nextSendraises. -
Mainclosesresultsafterwg.Wait( ).Mainis blocked in thefor eachdrainingresults, and never reaches theWait. Deadlock: the workers block on a fullresultschannel, andMainblocks waiting for a close that can only happen after it stops draining.
So the close has to happen concurrently with the drain, by something whose only
job is to wait and close. That is CloseWhenDone — three lines, and the whole
program hangs without it.
Rule of thumb: the channel a fan-in writes to is closed by a dedicated
goroutine that does wg.Wait( ) then Close( ).
-
CPU-bound work (hashing, parsing, compressing): roughly the number of
cores. More just adds context switching.
WorkerCount( )reports the pool's current size. - I/O-bound work (network, disk): more than the core count, because most workers are blocked in a syscall and not using a core at all. But every one blocked in a syscall holds an OS thread, and the pool caps at 256.
- Enqueueing 10,000 jobs into a channel is fine and normal. Spawning 10,000 goroutines is fine too. Spawning 10,000,000 is not.
Look at Worker's signature in the previous chapter:
sub Worker( byval id as int32, _
byval jobs as RecvChan( of int32 ), _
byval results as SendChan( of ustring ), _
byval wg as WaitGroup ptr )RecvChan and SendChan are one-way views of a Channel:
-
SendChan( of T )— hasSend,TrySend,SendFor,Close. No receive. -
RecvChan( of T )— hasReceive,TryReceive,ReceiveFor,for each. No send, and deliberately noClose.
The reason RecvChan has no Close is chapter 4's rule made mechanical: closing
is the producer's statement, and a consumer that closes its input is the single
most common channel bug there is.
'' ex08 -- directional channels write the contract into the signature
sub Produce( byval out_ as SendChan( of int32 ) ) '' can send + close, cannot receive
for i as int32 = 1 to 3
out_.Send( i * 10 )
next
out_.Close( )
end sub
sub Consume( byval in_ as RecvChan( of int32 ), byval done_ as SendChan( of boolean ) )
dim as int32 v '' cannot send, cannot close
while in_.Receive( v )
print "consumed " & v
wend
done_.Send( TRUE )
end subA Channel converts to either view implicitly, so callers just pass the channel.
The conversion is one-way — there is no cast back to Channel, because a
directional handle is a narrowing and narrowing should not be reversible by
accident.
Two honest caveats:
-
These are ergonomics, not safety. Nothing stops a caller from keeping the
full
Channeland using both ends. What they buy you is that the intent is written down at the boundary, where it can be checked by a reader and by the compiler. -
select channeldoes not accept them. Its cases require a realChannel( of T ); aRecvChanproducesTKO0809. If a procedure needs toselectover a channel, take theChannel(see chapter 10'sSearchFolder).
Receive waits on one channel. Real programs wait on several: a job queue
and a cancel signal, a cache and a network, an inbox and a timer.
select channel waits on all of them at once and runs the case that becomes
ready first. It needs no extra import -- the statement comes from the same
module as the channels:
#import "std/tasks"'' ex09 -- select channel: whichever source answers first
#import "std/tasks"
using tasks
sub Feed( byval ch as Channel( of ustring ), byval label_ as ustring, _
byval ms as uint32, byval n as int32 )
for i as int32 = 1 to n
sleep ms, 1
ch.Send( label_ & " #" & i )
next
end sub
sub Main( )
dim cache_ as Channel( of ustring ) = Channel( of ustring )( 4 )
dim net as Channel( of ustring ) = Channel( of ustring )( 4 )
spawn Feed( cache_, "cache", 120, 3 )
spawn Feed( net, "net", 300, 2 )
dim as ustring a, b
dim as int32 left_ = 5
do
select channel
case receive a from cache_ : print "from cache: " & a
case receive b from net : print "from net : " & b
case timeout 1000 : print "nothing in 1 s -- giving up" : exit do
end select
left_ -= 1
loop until left_ = 0
print "done"
end sub
Main( )from cache: cache #1
from cache: cache #2
from net : net #1
from cache: cache #3
from net : net #2
done
select channel
case receive job from jobs : Process( job ) '' receive into a variable
case receive from ctx.Done( ) : exit do '' receive and discard
case send result to results : sent += 1 '' send, when there is room
case timeout 500 : Heartbeat( ) '' nothing ready in 500 ms
case else : DoOtherWork( ) '' nothing ready RIGHT NOW
end select-
case receive v from chneedsvalready declared — the statement does not introduce it. -
case receive from chreceives and throws the value away. This is how aDone( )signal channel is used, where the arrival is the whole message. -
case send v to chwaits for room inch.selectis not receive-only. -
case timeout <ms>fires when nothing else became ready in time. At most one per statement. -
case elsemakes the whole statement non-blocking — it fires the instant nothing is ready. This is Go'sdefault.
case else and case timeout cannot appear together, and that is a compile
error rather than a puzzle: else would fire immediately, every time, so the
timeout could never be reached.
Cases are polled in a pseudo-random order. With a fixed order, a channel that is always ready would starve every case after it — and the failure would only appear under load, which is the worst time to discover it.
A nil channel case is permanently not-ready. That is how you disable a case without rewriting the statement — and it is what the nil channel from chapter 3 is for.
A closed channel is permanently ready for receive, and it fires with the
closed-channel answer. A select case cannot, by itself, tell "received a
value" from "the channel closed." A select in a loop over a channel that has
closed will spin at full speed forever.
The fix uses the rule from chapter 4: Receive does not modify its output when
it returns FALSE. So write a sentinel into the variable before the wait; if
it is still there afterwards, the case fired on the close. Then nil the channel
out, which permanently disables that case.
'' ex09b -- a closed channel is ALWAYS ready. Detect it, then nil the case out.
sub Main( )
dim cache_ as Channel( of ustring ) = Channel( of ustring )( 4 )
dim net as Channel( of ustring ) = Channel( of ustring )( 4 )
spawn Feed( cache_, "cache", 100, 3 ) '' Feed now Closes when finished
spawn Feed( net, "net", 250, 2 )
dim as ustring a, b
dim as int32 live = 2
do
'' Receive leaves the variable UNTOUCHED when the channel is closed and
'' drained, so a sentinel written before the wait says which happened.
a = "" : b = ""
select channel
case receive a from cache_
if( len( a ) = 0 ) then
cache_ = Channel( of ustring )( ) '' nil -> case disabled
live -= 1
print "cache stream ended"
else
print "from cache: " & a
end if
case receive b from net
if( len( b ) = 0 ) then
net = Channel( of ustring )( )
live -= 1
print "net stream ended"
else
print "from net : " & b
end if
case timeout 2000
print "stalled" : exit do
end select
loop until live = 0
print "both streams closed"
end subfrom cache: cache #1
from cache: cache #2
from net : net #1
from cache: cache #3
cache stream ended
from net : net #2
net stream ended
both streams closed
exit do and goto out of a case body are supported; the wait records are still
released properly.
exit for out of a case body is not. In a scope holding objects or var-length
strings — which any program using channels does — it fails to compile:
error 105: Branch crossing local array, var-len string or object definition
Write select channel loops as do ... loop, not for ... next. Both examples
above do.
Two more small guarantees: each channel expression is evaluated exactly once
even though the lowering needs it three times, and channel is contextual —
ordinary select case, including select case on a variable named channel, is
untouched. Only the pair select channel claims the statement.
When you need a timeout on exactly one channel operation, select is more
machinery than the job needs. Every blocking operation has a timed form.
| Blocking | Never blocks | With a deadline |
|---|---|---|
Send( v ) |
TrySend( v ) |
SendFor( v, ms ) |
Receive( out ) |
TryReceive( out ) |
ReceiveFor( out, ms ) |
The Try* and *For forms return FALSE rather than raising when they give up.
'' ex18 -- a deadline on ONE operation, without a select
#import "std/tasks"
using tasks
sub SlowService( byval reply as Channel( of ustring ), byval ms as uint32 )
sleep ms, 1
reply.Send( "server said hello" )
end sub
sub Ask( byval delay_ as uint32, byval budget as uint32 )
dim reply as Channel( of ustring ) = Channel( of ustring )( 1 )
spawn SlowService( reply, delay_ )
dim as ustring answer
if( reply.ReceiveFor( answer, budget ) ) then
print "within " & budget & " ms: " & answer
else
print "timed out after " & budget & " ms"
end if
end sub
Ask( 100, 400 )
Ask( 600, 300 )
sleep 500, 1 '' let the abandoned goroutine finish before we exitwithin 400 ms: server said hello
timed out after 300 ms
Note the honest bit at the end. Giving up on the answer does not stop the
work: SlowService is still running after Ask returns. That is what chapter
10 is for.
Why every blocking operation here offers a timeout at all: a goroutine blocked in a syscall cannot be cancelled. Timeouts are the mechanism you actually have, so they are everywhere.
The user clicks Stop. The deadline passes. One worker in a pool of eight hits a fatal error and the other seven are now wasting their time.
A Context is the standard way to say "you can stop now" to any number of
goroutines at once.
dim ctx as CancelContext = WithCancel( Context( ) )
spawn Worker( ctx )
...
ctx.Cancel( ) '' ask it to stopContext( ) |
the background context; never cancelled |
WithCancel( parent ) |
a cancellable child |
WithTimeout( parent, ms ) |
cancels itself when the deadline passes |
ctx.IsCancelled( ) |
a cheap boolean poll |
ctx.Done( ) |
a channel that is closed on cancel |
ctx.Err( ) |
"", "cancelled", or "deadline exceeded"
|
CancelContext.Cancel( ) |
ask everything derived from this context to stop |
Done( ) returns a channel that is closed when the context is cancelled.
That is the entire design, and it is why cancellation is modelled as a channel:
a closed channel is permanently ready, so cancellation composes with every
other channel operation for free — including select.
do
select channel
case receive job from jobs : Process( job )
case receive from ctx.Done( ) : exit do
end select
loopThat loop takes work while there is work, and leaves the moment cancellation arrives, with no polling and no flag.
'' ex10 -- cancellation: the user hits Stop, or the deadline passes
#import "std/tasks"
using tasks
sub SearchFolder( byval ctx as Context, _
byval jobs as Channel( of int32 ), _
byval hits as SendChan( of ustring ), _
byval wg as WaitGroup ptr )
dim as int32 n
do
select channel
case receive from ctx.Done( ) '' cancelled -- pack up
exit do
case receive n from jobs
if( n = 0 ) then exit do '' sentinel: stream ended
sleep 80, 1 '' pretend: scan a file
if( n mod 3 = 0 ) then hits.Send( "match in file " & n )
end select
loop
wg->Done( )
end sub
sub Main( )
dim ctx as CancelContext = WithTimeout( Context( ), 500 )
dim jobs as Channel( of int32 ) = Channel( of int32 )( 64 )
dim hits as Channel( of ustring ) = Channel( of ustring )( 64 )
dim wg as WaitGroup
for i as int32 = 1 to 40
jobs.Send( i )
next
for i as int32 = 1 to 3
wg.Add( )
spawn SearchFolder( ctx, jobs, hits, @wg )
next
wg.Wait( )
hits.Close( )
dim as int32 found = 0
for each h in hits
found += 1
next
print "stopped after " & found & " hits; reason: " & ctx.Err( )
end sub
Main( )stopped after 7 hits; reason: deadline exceeded
Forty files of work, a 500 ms budget, three workers, a clean stop, and an
accurate reason. WithCancel instead of WithTimeout gives you the Stop-button
version — same code, and ctx.Err( ) then reads "cancelled".
-
Cancellation is a request. Nothing is ever killed. A goroutine that never checks its context runs to completion, and
Cancelchanges nothing observable in it. This is deliberate:TerminateThreadskips destructors anddeferblocks and leaks every mutex the thread held. In a language that hasdefer, forced termination means the cleanup written specifically to be unmissable is missed. The name suggests otherwise, which is why this paragraph exists. -
Cancelis idempotent, unlikeChannel.Close. A context is routinely cancelled by both a timeout and an explicitCancel, so "two parties" is normal here rather than a bug. -
Cancellation propagates down, and only down. Cancelling a parent cancels every context derived from it, transitively; cancelling a child never touches its parent. A context derived from an already-cancelled parent starts cancelled.
-
Use
defer ctx.Cancel( )after creating a context, to release the registration even on an early return. It is safe precisely becauseCancelis idempotent.
A runtime error inside a goroutine does not propagate anywhere. The
worker's error state is reset between work items so it cannot leak into the next
goroutine — and nobody observes it. There is no on error in the caller that can
see it, because there is no caller.
So an error is a value you send, like any other result. Result( of T, E ) is
made for it: it holds either a value or a reason, and IsOk( ) says which.
'' ex11 -- errors do not propagate out of a goroutine. Send them.
#import "std/tasks"
#import "std/outcome"
using tasks, outcome
type ParseResult as Result( of int32, ustring )
sub ParseNumber( byval text_ as ustring, byval out_ as Channel( of ParseResult ) )
dim as int32 v = 0
if( len( text_ ) = 0 ) then
out_.Send( Fail( of int32, ustring )( "empty input" ) )
exit sub
end if
for i as int32 = 0 to len( text_ ) - 1
dim as int32 d = text_[i] - asc( "0" )
if( d < 0 orelse d > 9 ) then
out_.Send( Fail( of int32, ustring )( "'" & text_ & "' is not a number" ) )
exit sub
end if
v = v * 10 + d
next
out_.Send( Ok( of int32, ustring )( v ) )
end sub
sub Main( )
dim res as Channel( of ParseResult ) = Channel( of ParseResult )( 4 )
dim as ustring inputs(0 to 2) = { "1234", "12x4", "" }
for i as int32 = 0 to 2
spawn ParseNumber( inputs(i), res )
next
dim as ParseResult r
for i as int32 = 0 to 2
res.Receive( r )
if( r.IsOk( ) ) then
print "ok : " & r.Value( )
else
print "fail: " & r.Failure( )
end if
next
end sub
Main( )ok : 1234
fail: '12x4' is not a number
fail: empty input
Notes on Result:
- Both type arguments must be written out at construction —
Ok( of int32, ustring )( v )— because inference reads parameter positions only and each factory mentions just one ofTandE. Thetype ParseResult as Result( of int32, ustring )alias is the usual way to say it once. -
Value( )on a failure andFailure( )on a success both raise.IsOk( )is the guard, and it costs nothing. - Note that the results arrive in a different order from the inputs — three
independent goroutines, no promised order. If you need to know which input a
result belongs to, put the key in the message: send a type with both the id
and the
Result, not a bareResult.
A pipeline is a chain of stages, each a goroutine, connected by channels: stage 1 reads, stage 2 transforms, stage 3 writes. All three run at the same time, on different items — like an assembly line, where the first item is being painted while the third is still being cut.
The rule that makes it compose: each stage owns its output channel and closes it when its input runs dry. That way the close cascades down the chain on its own.
'' ex12 -- a pipeline: read -> clean -> count, three stages running at once
#import "std/tasks"
using tasks
sub Stage1_Read( byval out_ as SendChan( of ustring ) )
dim as ustring src(0 to 4) = { " Hello ", "WORLD", "", " Fbc ", "Modern" }
for i as int32 = 0 to 4
sleep 40, 1
out_.Send( src(i) )
next
out_.Close( ) '' each stage closes ONLY its own output
end sub
sub Stage2_Clean( byval in_ as Channel( of ustring ), byval out_ as SendChan( of ustring ) )
dim as ustring s
while in_.Receive( s )
s = lcase( trim( s ) )
if( len( s ) > 0 ) then out_.Send( s ) '' a stage may drop items
wend
out_.Close( )
end sub
sub Stage3_Number( byval in_ as Channel( of ustring ), byval out_ as SendChan( of ustring ) )
dim as ustring s
dim as int32 n = 0
while in_.Receive( s )
n += 1 '' stage-local state: nobody else sees it
out_.Send( n & ": " & s )
wend
out_.Close( )
end sub
sub Main( )
dim raw as Channel( of ustring ) = Channel( of ustring )( 2 )
dim clean as Channel( of ustring ) = Channel( of ustring )( 2 )
dim final_ as Channel( of ustring ) = Channel( of ustring )( 2 )
spawn Stage1_Read( raw )
spawn Stage2_Clean( raw, clean )
spawn Stage3_Number( clean, final_ )
for each line_ in final_
print line_
next
end sub
Main( )1: hello
2: world
3: fbc
4: modern
Things a pipeline gives you that a loop does not:
- Overlap. Stage 1 is fetching item 4 while stage 3 is writing item 2.
- Backpressure end to end. Small buffers mean a slow final stage throttles the reader automatically, and memory use stays bounded no matter how big the input.
-
Stage-local state.
Stage3_Number's counternis a plain local. Only one goroutine touches it, so it needs no protection whatsoever. This is the deepest idea in the whole model — see chapter 14. - A widenable stage. If one stage is the bottleneck, spawn several copies of that stage reading the same input channel (chapter 6's fan-out), and let a closer goroutine close its output. Ordering is lost when you do; if it matters, carry a sequence number in the item.
"Concurrent" does not mean "all at once, unbounded." Fifty simultaneous HTTP requests will get you rate-limited; fifty simultaneous disk readers will thrash.
A channel with capacity N, pre-filled with N permits, is a counting semaphore. Take a permit before the work, give it back after.
'' ex17 -- a channel as a semaphore: at most N things in flight at once
#import "std/tasks"
using tasks
sub Download( byval id as int32, byval slots as Channel( of boolean ), byval wg as WaitGroup ptr )
dim as boolean tok
slots.Receive( tok ) '' take a permit (blocks if none free)
print " -> start " & id
sleep 200, 1
print " <- done " & id
slots.Send( TRUE ) '' give it back
wg->Done( )
end sub
sub Main( )
const LIMIT = 3
dim slots as Channel( of boolean ) = Channel( of boolean )( LIMIT )
dim wg as WaitGroup
for i as int32 = 1 to LIMIT
slots.Send( TRUE ) '' LIMIT permits in the pool
next
for i as int32 = 1 to 9
wg.Add( )
spawn Download( i, slots, @wg )
next
wg.Wait( )
print "all downloads finished, never more than " & LIMIT & " at once"
end sub
Main( ) -> start 1
-> start 2
-> start 3
<- done 2
<- done 3
<- done 1
-> start 4
-> start 5
-> start 6
<- done 4
<- done 5
<- done 6
-> start 8
-> start 7
-> start 9
<- done 9
<- done 7
<- done 8
all downloads finished, never more than 3 at once
Never more than three -> lines before a <-. All nine goroutines were spawned
immediately; six of them were parked on slots.Receive costing nothing.
A worker pool (chapter 6) limits concurrency too, and is usually the better tool. Reach for a semaphore when the goroutines are already structured some other way — a per-request goroutine, say — and you only want to cap one particular resource.
A semaphore caps how many run at once. A rate limit caps how many start per unit of time. A ticker goroutine gives you one.
'' ex13 -- polling without blocking, and pacing a loop
#import "std/tasks"
using tasks
'' A ticker: one value every 'ms', for 'n' ticks, then closed.
sub Ticker( byval ch as Channel( of boolean ), byval ms as uint32, byval n as int32 )
for i as int32 = 1 to n
sleep ms, 1
ch.Send( TRUE )
next
ch.Close( )
end sub
sub Main( )
dim work as Channel( of int32 ) = Channel( of int32 )( 16 )
dim tick as Channel( of boolean ) = Channel( of boolean )( 1 )
for i as int32 = 1 to 5
work.Send( i * 100 )
next
spawn Ticker( tick, 150, 5 )
'' Rate limit: do at most one request per tick.
dim as boolean t
dim as int32 v
dim as int32 served = 0
while served < 5
tick.Receive( t ) '' the pacing gate
if( work.TryReceive( v ) ) then '' TryReceive: never blocks
served += 1
print "request " & served & " sent (payload " & v & ")"
else
print "nothing queued this tick"
end if
wend
'' CASE ELSE makes a select non-blocking: it fires when nothing is ready.
select channel
case receive v from work : print "unexpected leftover " & v
case else : print "queue is empty, as expected"
end select
end sub
Main( )request 1 sent (payload 100)
request 2 sent (payload 200)
request 3 sent (payload 300)
request 4 sent (payload 400)
request 5 sent (payload 500)
queue is empty, as expected
The last statement shows the other non-blocking tool: case else turns a
select into a poll. It fires the moment nothing is ready, instead of waiting.
Give the ticker channel capacity 1 and the whole thing tolerates a slow consumer gracefully: ticks that nobody collects are simply not produced, because the ticker blocks on a full channel rather than building a backlog of catch-up ticks.
This chapter is the one to read twice.
There is no data-race protection of any kind here. None. A channel will happily carry a type holding a pointer into another goroutine's stack. This design makes concurrency ergonomic, not safe.
Here is what that means in practice. Eight goroutines, each adding 1 to a shared global 200,000 times. The answer must be 1,600,000.
'' ex15 -- there is NO data-race protection. This is a broken program.
#import "std/tasks"
using tasks
dim shared as int32 g_counter
sub BumpUnsafe( byval times_ as int32, byval wg as WaitGroup ptr )
for i as int32 = 1 to times_
g_counter += 1 '' read, add, write -- three steps
next
wg->Done( )
end sub
sub Main( )
dim wg as WaitGroup
for i as int32 = 1 to 8
wg.Add( )
spawn BumpUnsafe( 200000, @wg )
next
wg.Wait( )
print "expected 1600000, got " & g_counter
end sub
Main( )expected 1600000, got 193575
Seven-eighths of the increments vanished. No crash, no error, no warning —
just a wrong number. Run it again and you get a different wrong number; the next
run of that exact program printed 304394.
g_counter += 1 is not one operation. It is read the value, add one, write
it back. Two goroutines that read 41 at the same time both write 42, and one
increment is gone. Nothing in the language, the compiler, or this library will tell
you: a data race is silent, machine-dependent, and load-dependent, which is why
it reliably passes your tests and fails on a customer's machine.
You could reach for a mutex. The better fix is to arrange for exactly one goroutine to own the variable, and have the others send it messages.
Do not communicate by sharing memory; share memory by communicating.
'' ex16 -- the same count, owned by ONE goroutine. Correct by construction.
#import "std/tasks"
using tasks
sub Bump( byval times_ as int32, byval ticks as SendChan( of int32 ), byval wg as WaitGroup ptr )
for i as int32 = 1 to times_
ticks.Send( 1 )
next
wg->Done( )
end sub
sub CloseWhenDone( byval ticks as Channel( of int32 ), byval wg as WaitGroup ptr )
wg->Wait( )
ticks.Close( )
end sub
sub Main( )
dim ticks as Channel( of int32 ) = Channel( of int32 )( 1024 )
dim wg as WaitGroup
for i as int32 = 1 to 8
wg.Add( )
spawn Bump( 50000, ticks, @wg )
next
spawn CloseWhenDone( ticks, @wg )
dim as int32 total = 0 '' only THIS goroutine touches it
for each t in ticks
total += t
next
print "expected 400000, got " & total
end sub
Main( )expected 400000, got 400000
Correct, and correct by construction: total is a plain local, in one
goroutine, and no amount of concurrency elsewhere can reach it. There is nothing
to get wrong, so there is nothing to review, and no test can pass by luck.
(Sending one message per increment is of course slower than an atomic add. The point is the ownership pattern. In a real program each message carries a meaningful unit of work, not one integer.)
- A value is owned by exactly one goroutine at a time. Sending it through a channel transfers ownership; the sender must not touch it afterwards.
-
Prefer stage-local state.
Stage3_Number's counter in chapter 12 needs no protection because only one goroutine can see it. - If you must share, share immutable data. A value nobody writes cannot race.
MutexCreate / MutexLock / MutexUnlock exist and are orthogonal to all
of this. They are the right tool when the shared thing is a real resource with an
identity — a log file, a cache, a hardware handle — rather than a value that
could just as well be passed along. But reach for them second, not first.
if( ch.Count( ) > 0 ) then ch.Receive( v ) '' WRONG -- and it will pass your testsCount( ) is a snapshot and is stale the instant it returns; another goroutine
can drain the channel between the if and the Receive, and now you are blocked
forever. Use TryReceive, which does the check and the take as one indivisible
step. Count( ) is for progress bars and logging. So is IsClosed( ).
This is the case that matters most in a Win32 program, and it has one rule.
Window handles belong to the thread that created them. A goroutine runs on an
arbitrary worker thread. So a SetWindowText, a ListView_InsertItem, a
SelectObject or a COM call from a goroutine is a bug — and the worst kind,
because it appears to work for months and then corrupts state on a customer's
machine under load.
The rule:
Compute in the goroutine. Hand the result back through a channel. Let the UI thread pick it up.
And the corollary that makes it work: the UI thread drains with TryReceive,
never Receive. A blocking Receive on the UI thread freezes the window — which
is the exact bug the goroutine was introduced to fix.
'' ex14 -- the desktop-app shape: a background scan, drained by the UI thread
#import "std/tasks"
using tasks
type ScanResult
as ustring path_
as int64 bytes_
end type
'' Runs on an ARBITRARY worker thread. It must not touch a window, a GDI
'' object, or anything else owned by the UI thread.
sub ScanFolder( byval out_ as Channel( of ScanResult ), byval n as int32 )
for i as int32 = 1 to n
sleep 90, 1 '' pretend: stat a file
dim as ScanResult r
r.path_ = "C:\data\file" & i & ".dat"
r.bytes_ = i * 4096
out_.Send( r )
next
out_.Close( )
end sub
'' Stands in for WM_TIMER: called on the UI thread, must return immediately.
function OnTimerTick( byref results as Channel( of ScanResult ), byref rows as int32 ) as boolean
dim as ScanResult r
while results.TryReceive( r ) '' TryReceive, never Receive
rows += 1
print " row " & rows & ": " & r.path_ & " (" & r.bytes_ & " bytes)"
wend
return ( results.IsClosed( ) andalso results.Count( ) = 0 )
end function
sub Main( )
dim results as Channel( of ScanResult ) = Channel( of ScanResult )( 64 )
spawn ScanFolder( results, 6 )
dim as int32 rows = 0
do
print "[pump] window still responsive"
sleep 200, 1 '' stands in for the message loop idling
loop until OnTimerTick( results, rows )
print "scan complete: " & rows & " rows"
end sub
Main( )[pump] window still responsive
row 1: C:\data\file1.dat (4096 bytes)
row 2: C:\data\file2.dat (8192 bytes)
[pump] window still responsive
row 3: C:\data\file3.dat (12288 bytes)
row 4: C:\data\file4.dat (16384 bytes)
[pump] window still responsive
row 5: C:\data\file5.dat (20480 bytes)
row 6: C:\data\file6.dat (24576 bytes)
scan complete: 6 rows
Replace the do ... loop with your message loop and OnTimerTick with a real
handler. Two ways to trigger it:
-
WM_TIMER.SetTimerat, say, 100 ms; drain in the handler;KillTimerwhen the channel is closed and empty. Simplest, and the drain rate is predictable — good for a progress list where a hundred rows a tick is fine. -
PostMessagewakeup. The goroutine callsPostMessage( hwnd, WM_APP + 1, 0, 0 )after sending, and the handler drains. Lower latency, no polling.PostMessageis one of the few Win32 calls that is explicitly safe to make from another thread — it queues a message rather than touching the window.SendMessageis not: it blocks until the target thread processes it, so it can deadlock.
Either way the handler drains with TryReceive in a while loop and returns to
the pump immediately.
Three more notes:
-
Cancel with a
Context(chapter 10) so the Stop button and window destruction can both end the scan. Do not close the window while a goroutine still holds itsHWND. -
The channel type carries plain data.
ScanResultholds austringand aint64— noHWND, no GDI handle, no interface pointer. Keep it that way. - Buffer generously (64, 256) so the scan is not throttled by your timer interval. If you want it throttled, a small buffer does that for free.
Print this. Almost every bug in a first concurrent program is on it.
Build
-
-mton every build.std/tasksrefuses to compile without it, rather than linking a single-threaded runtime and misbehaving quietly.
Lifetime
- A goroutine still running at program exit is abandoned, silently. Use a
WaitGroupor a channel if it must finish. -
wg.Addbefore thespawn, never inside the goroutine. -
wg.Doneon every exit path — a missed one leavesWaitblocked forever. Usedefer. - A
WaitGroupis non-copyable. Pass@wg, usewg->Done( ).
Channels
- The producer closes; the consumer never does.
RecvChanhas noClosefor this reason. - Several producers: none of them closes. A dedicated closer goroutine
does
wg.Wait( )thenClose( ). -
SendafterCloseraises.Closetwice raises. These are structural bugs, not conditions to handle. -
ReceivereturningFALSEis end-of-stream, not an error — and the output variable is left untouched. - A nil channel blocks forever.
IsNil( )tells you. Constructing without a capacity argument gives you one. -
Count( )andIsClosed( )are snapshots. Metrics only, never control flow. UseTryReceive. - Capacity 0 is a rendezvous, not a zero-length buffer.
- For a large
T, send a pointer. Every send copies, and a buffered send copies twice; there is no move constructor in the language.Tmust be default-constructible, copy-constructible and destructible.
spawn
- A
byrefparameter is a compile error at a spawn site. Pass a pointer if sharing is what you mean. - Every argument must be written out — no omitted default arguments.
- No methods, no lambdas, no procedure pointers, no procedures inside a namespace. Procedure pointers and lambdas are TKO0943; a method or a namespace procedure stops the compiler with an internal error (TKO0994).
- A spawned
function's return value is discarded. - Thousands of goroutines, not millions.
-
spawndoes not work inside a generic member body (a generic procedure is fine) — a pre-existing limitation of nested body replay.
select channel
-
#import "std/tasks"in the file that uses it. - A closed channel is always ready. Detect the close and nil the channel out, or your loop spins at full speed forever.
-
case elseandcase timeoutcannot coexist — a compile error. -
case receive v from chneedsvalready declared. - Cases require a real
Channel( of T ), not aSendChan/RecvChan. Otherwise:TKO0809. - Use
do ... loop, notfor ... next.exit forout of a case body hitserror 105: Branch crossing local ... object definition.exit doandgotoare fine.
Correctness
- There is no data-race protection of any kind. Own values; don't share them.
- A runtime error inside a goroutine propagates nowhere. Send a
Result( of T, E )instead. - Cancellation is advisory. A goroutine that ignores its context blocks its waiters forever. Nothing is ever killed.
- Never touch a window, a GDI object or a COM apartment from a goroutine.
PostMessageis safe;SendMessageis not. - Beware reserved words as parameter names:
name,line,close,time,date,error,left,right.
Diagnosing a hang
There is no deadlock detector, and there cannot be a useful one: Go can say "all
goroutines are asleep" because it knows about every block, but this runtime
cannot see a goroutine parked in ReadFile. When a program hangs, walk this
list in order — it is nearly always one of the first three:
- A channel nobody closed, with a
for eachorwhile Receivewaiting on it. - A
wg.Done( )that some path skips. - A nil channel — check
IsNil( ). - A rendezvous channel (capacity 0) with no receiver ready.
- Two goroutines each blocked sending to a full channel the other should drain.
- A
Receiveon the UI thread. (The window is frozen, not the program.)
Add a WaitFor( ms ) or a ReceiveFor( v, ms ) in place of the blocking form
and print when it times out; that usually names the culprit in one run.
| Open, ready | Open, would block | Closed, has items | Closed, empty | Nil channel | |
|---|---|---|---|---|---|
Send |
TRUE | blocks | raises | raises | blocks forever |
Receive |
TRUE | blocks | TRUE, drains | FALSE | blocks forever |
TrySend |
TRUE | FALSE | raises | raises | FALSE |
TryReceive |
TRUE | FALSE | TRUE | FALSE | FALSE |
SendFor |
TRUE | FALSE @ms | raises | raises | FALSE @ms |
ReceiveFor |
TRUE | FALSE @ms | TRUE | FALSE | FALSE @ms |
Close |
— | — | raises | raises | raises |
"raises" is runtime error 1, illegal function call — the same one
Optional.Value( ) raises on an empty Optional.
Everything is one module, #import "std/tasks"; its files are:
| File | What it brings |
|---|---|
channel.tiko |
Channel, SendChan, RecvChan
|
scheduler.tiko |
tasks.Spawn, GoroutineCount, WorkerCount
|
select.tiko |
the SELECT CHANNEL runtime — the statement needs the import |
sync.tiko |
WaitGroup, Context
|
In practice: #import "std/tasks" plus -mt covers everything.
| You want to… | Use |
|---|---|
| run something concurrently | spawn |
| get one answer back |
Channel( of T )( 1 ) + Receive
|
| stream many values | buffered channel + Close + for each
|
| know when N things have finished | WaitGroup |
| spread work over cores | worker pool: N × spawn on one jobs channel |
| collect results from N workers | fan-in + a closer goroutine |
| wait on several channels | select channel |
| poll without blocking |
TryReceive / TrySend, or case else
|
| give up after a while |
ReceiveFor / SendFor, or case timeout
|
| stop work early |
Context + WithCancel / WithTimeout
|
| report a failure from a goroutine | send a Result( of T, E )
|
| cap concurrent operations | channel-as-semaphore, or a worker pool |
| cap operations per second | ticker goroutine |
| keep a window responsive | goroutine + TryReceive in WM_TIMER
|
| document a channel's direction |
SendChan / RecvChan
|
- Cheap goroutines. Thousands, not millions — see chapter 1.
-
Data-race detection, or
Send/Syncmarkers. They need a type system tiko does not have, and a marker the compiler cannot enforce is documentation in a costume. - Forced cancellation. See chapter 10.
- A value returned from a goroutine. Go has none either; the channel is the return path.
- Broadcast channels, priorities, peek. A channel is a FIFO queue and each item goes to exactly one receiver.
- A deadlock detector. See chapter 16.
-
sync.Once,RWMutex, a scoped lock guard. Worth having, orthogonal to this, andMutexCreate/MutexLockalready exist.
Abandoned — a goroutine still running when the program exits. It is dropped
silently. Prevent with a WaitGroup.
Backpressure — a full channel blocking its producer, forcing it down to the consumer's rate. The reason capacity is bounded.
Blocking — an operation that does not return until it can proceed. Cheap and correct in a goroutine; fatal on a UI thread.
Buffered channel — capacity ≥ 1. Send blocks only when full.
Concurrency — a program structured as independent activities. Contrast parallelism: activities executing at the same instant.
Context — a cancellation signal that composes, because Done( ) is a channel
that closes on cancel. Advisory: nothing is ever killed.
Data race — two goroutines touching the same memory with at least one writing, unordered. Silent, machine-dependent, and undetected here.
Deadlock — every party waiting for another, so nothing proceeds. There is no detector; see the checklist in chapter 16.
Drain — receive from a channel until it is closed and empty.
for each v in ch is the idiom.
Fan-in — several goroutines sending to one channel. Needs a closer goroutine.
Fan-out — several goroutines receiving from one channel. Each item goes to exactly one of them, so the work self-balances.
Goroutine — a work item on a thread pool. Not Go's stackful coroutine.
Nil channel — a default-constructed Channel. Blocks forever; used to
disable a select case.
Parked — blocked, with the worker thread released to run something else.
Pipeline — stages connected by channels, all running at once on different items.
Rendezvous channel — capacity 0. Send returns only after a receiver takes
the value: a handoff and a synchronisation point.
Semaphore — here, a channel pre-filled with N permits, capping how many operations run at once.
Spawn — start a goroutine. Arguments are evaluated at the spawn site and copied.
WaitGroup — a counter with a queue: Add, Done, Wait. Non-copyable.
Worker pool — N goroutines fanning out from one jobs channel and fanning in to one results channel. The most common shape you will write.
-
goroutines.txt— the reference. Every rule, tersely. Read it now that the ideas have names. -
tests/mir/spawn.testin the tiko-lang tree — howspawnis lowered;tests/mir/spawn-contextual.testbeside it keepsspawnusable as a name.
Category: Goroutines and Channels
tiko-lang · Home · Feature Index · Keyword Index · License
FreeBASIC manual pages reproduced under the GNU FDL 1.3.
- Generics
- Interfaces
- For Each
- Iterators
- Tuples
- Array Results
- Defer
- Lambdas
- Block Strings
- Partial Types
- String Slices
- Embed
- Typed Variadics
- C Header Import
- Foreign Library Linking
- UString
- Fixed-Width Types
- Nullptr
- String Library
- Format
- Array
- Map
- Set
- LinkedList
- OrderedMap
- OrderedSet
- Sorting
- Optional
- Result
- Smart Pointers
- Date and Time (Chrono)
- Filesystem
- File Streams
- Config (INI and TOML)
- Codec
- Hash
- UUID
- OS
- Process
- Net
- HTTP and TLS
- Math Library
- Finance
- Logging
- Terminal TUI Library
- PsControls
- tkControls
- sys/win
- Goroutines and Channels
- LLVM Backend
- tiko build
- TIKO_COMPILER
- Variable Declarations
- Standard Data Types
- User Defined Types
- Procedures
- Operators
- Control Flow Statements
- Converting Data Types
- Array Functions
- String Functions
- Mathematical Functions
- Bit Manipulation
- Date and Time Functions
- File I/O Functions
- Console Functions
- User Input (Runtime Library)
- User Input Functions (Graphics Library)
- Screen Functions
- 2D Drawing Functions
- Graphics Library Reference
- Graphics Keyword List
- Memory Functions
- Threading Support Functions
- Error Handling Functions
- Operating System Functions
- Preprocessor
- Intrinsic Defines
- Miscellaneous Keywords
- Modularizing