Session 14¶
Topic 2: The Go language¶
- tour of Go
- tour of Go (continued)
- Go’s programming syntax
- many workers with Goroutines
- shared memory between workers
- message passing with Go channels 👈
- case studies with concurrency
- performance and scalability
- case studies with parallel algorithms
Today¶
Message passing with Go channels¶
- channels – unbuffered, buffered, and unidirectional
- choosing channels: multiplexing
- example: bank account with channels
Channels in Go¶
Why bother?¶
concurrency is messy without structure:
think of race conditions 🙇
- non-shared memory → safe to use concurrently 🙆
- shared memory → requires synchronization 🔒
- we should design critical sections to be small & fast
But what about not sharing memory in the first place? 🤷
Go’s approach: message passing¶
“Don’t communicate by sharing memory; instead, share memory by communicating.”
inspired by Communicating Sequential Processes
- avoid shared variables (when possible)
- goroutines confine variables → monitor goroutines
- communicate using channels (instead of accessing variables directly)
Conceptual difference¶
-
Mutex model:
multiple goroutines access a variable -
Channel model:
one goroutine owns a variable
gopl.io, p. 261:
Since other goroutines cannot access the variable directly, they must use a channel to send the confining goroutine a request to query or update the variable. This is what is meant by the Go mantra “Do not communicate by sharing memory; instead, share memory by communicating.” A goroutine that brokers access to a confined variable using channel requests is called a monitor goroutine for that variable.
What is a Go channel?¶
- channels are like pipes between goroutines
-
values flow through them – safe and structured
-
channels let goroutines send → receive values
- channels are typed – only carry values of one type
gopl.io, pp217:
Channels support communicating sequential processes or CSP, a model of concurrency in which values are passed between independent activities (goroutines) but variables are for the most part confined to a single activity.
The more traditional model of shared memory multithreading will be familiar if you’ve used threads in other mainstream languages.
Unbuffered Channels – the default¶
make(chan T) – unbuffered channel of type T
// create an unbuffered channel for integers
ch := make(chan int)
communication requires:
- one sending goroutine
- one receiving goroutine
communication acts as a synchronization point ⏳
→ sends and receives block until the other side is ready
Send → receive via an unbuffered channel¶
unbuffered channels behave like a handshake 🤝
| Statement | What it does |
|---|---|
ch <- ... |
📥 send value to channel |
... <-ch |
📤 receive value from channel |
ch := make(chan int) // create channel / allocate memory
go func() {
ch <- 34 // goroutine sends a value, then waits
}()
value := <-ch // goroutine waits until it receives a value
fmt.Println(value) // prints 34
Why do we need go here? – Because without go, the program deadlocks.
We need two goroutines: one sender and one receiver.
Buffered Channels – specific use¶
make(chan T, n) – buffered channel, capacity n
// create a buffered channel for strings with capacity 2
ch := make(chan string, 2)
buffer allows limited asynchronous communication:
- sender blocks only if buffer is full 📬
- receiver blocks only if buffer is empty 📭
Send → receive via buffered channel¶
ch := make(chan string, 2)
// send
ch <- "hi" // stores in buffer & continues
ch <- "there" // stores in buffer & continues
// receive
fmt.Println(<-ch) // "hi"
fmt.Println(<-ch) // "there"
buffer: a small queue between goroutines
sender → [ hi | there ] → receiver
↑ ↑
slot1 slot2
🧐 what if?
ch := make(chan string, 2)
ch <- "hi"
ch <- "there"
ch <- "everyone"
Answer: The third send blocks because the buffer is full.
Unidirectional channels¶
ch := make(chan int)
restrict channel use for clarity and safety
func sendData(ch chan<- int) { // function can only send to `ch`
ch <- 10
}
func receiveData(ch <-chan int) { // function can only receive from `ch`
fmt.Println(<-ch)
}
When to use unidirectional channels¶
code documents intent:
func producer(ch chan<- int) // only sends
func consumer(ch <-chan int) // only receives
- prevents accidental sends/receives inside functions
- makes the API clearer
Multiplexing with channels¶
What is multiplexing?¶
orchestrate multiple channels simultaneously
great for:
- timeouts
- fan-in
- cancellation
Timeout example¶
select {
case result := <-workerChannel:
fmt.Println(result)
case <-time.After(2*time.Second):
fmt.Println("timeout")
}
Recall: switch¶
It runs the first case whose value is equal to the condition expression.
today := time.Now().Weekday()
// evaluate cases from top to bottom, stopping when a case succeeds
switch time.Saturday {
case today + 0:
fmt.Println("Today.")
case today + 1:
fmt.Println("Tomorrow.")
case today + 2:
fmt.Println("In two days.")
default:
fmt.Println("Too far away.")
}
Multiplexing in Go with select¶
It waits on multiple channel operations.
select {
case <-ch1: // do nothing
case msg2 := <-ch2:
fmt.Println("Received", msg2)
case ch3 <- msg3:
fmt.Println("Sent", msg3)
default:
fmt.Println("No message sent/received")
}
→ executes whichever send/receive is ready first
⚠️ if multiple cases are ready, Go choses one randomly
gopl.io, pp. 244:
We can’t just receive from each channel because whichever operation we try first will block until completion. We need to multiplex these operations, and to do that, we need a select statement.
The general form of a select statement is shown above. Like a switch statement, it has a number of cases and an optional default. Each case specifies a communication (a send or receive operation on some channel) and an associated block of statements. A receive expression may appear on its own, as in the first case, or within a short variable declaration, as in the second case; the second form lets you refer to the received value.
Example: Multiplexing with a buffered channel¶
ch := make(chan int, 1) // create a channel with buffer size = 1
for i := 0; i < 10; i++ {
select {
case x := <-ch:
fmt.Println(x)
case ch <- i:
}
}
- only one goroutine that sends and receives
- prints:
0 2 4 6 8
gopl.io, p. 245
The channel
ch, whose buffer size is 1, is alternately empty then full, so only one of the cases can proceed, either the send wheniis even, or the receive wheniis odd. It always prints: “0” “2” “4” “6” “8”.If multiple cases are ready,
selectpicks one at random, which ensures that every channel has an equal chance of being selected. Increasing the buffer size of the previous example makes its output nondeterministic, because when the buffer is neither full nor empty, the select statement figuratively tosses a coin.
Example: bank account with channels¶
Recall: previous bank account code¶
code/go/bank3_race
demonstrated the use of shared variables and sync.Mutex
now:
confine the balance variable to one goroutine and use channels for
communication:
code/go/bank4_channels
code/go/bank4_channels/bank/bank.go
package bank
// WARNING: not concurrency safe
type Account struct {
balance int
}
func (a *Account) Balance() int {
return a.balance
}
func (a *Account) Deposit(amount int) {
a.balance = a.balance + amount
}
(original sequential code)
code/go/bank4_channels/main/main.go
func main() {
var a bank.Account
// Alice:
go func() {
a.Deposit(200) // A1
fmt.Println("$", a.Balance()) // A2
}()
// Bob:
go a.Deposit(100) // B1
}
Confine variable(s), create channels¶
Pattern: monitor goroutine¶
- one goroutine owns the state (here: account)
- other goroutines communicate via channels
→ no race conditions
→ no mutex needed
type Account struct {
balance int
balancesCh chan int // receive balance
depositsCh chan int // send amount to deposit
}
// teller is the only method accessing the variable `balance`
func (a *Account) teller() {
for {
select {
case amount := <-a.depositsCh:
a.balance = a.balance + amount
case a.balancesCh <- a.balance:
}
}
}
// only this goroutine modifies `balance`
go teller()
create channels and start the monitor goroutine:
func (a *Account) Init() {
a.depositsCh = make(chan int)
a.balancesCh = make(chan int)
go a.teller() // start the monitor goroutine for `a.balance`
}
Balance() and Deposit() only access channels:
func (a *Account) Balance() int {
return <-a.balancesCh
}
func (a *Account) Deposit(amount int) {
a.depositsCh <- amount
}
How do we manage withdrawals with channels?¶
code/go/bank4_channels/bank/bank.go
// original version, WARNING: not concurrency safe
func (a *Account) Withdrawal(requested int) (int, bool) {
if requested <= a.balance {
a.Deposit(-requested)
return requested, true
} else {
// insufficient funds, no withdrawal
return 0, false
}
}
// in main, we want to do:
go a.Deposit(200)
go a.Withdrawal(100)
→ exercise: create a version with channels
Hint: Define the structs
type Account struct {
balance int
balancesCh chan int // receive balance
depositsCh chan int // send amount to deposit
withdrawalsCh chan withdrawalRequest // request withdrawal
}
type withdrawalRequest struct {
amount int
resultCh chan bool // send back if withdrawal succeeded
}
then
- in
teller(), process receives in channelwithdrawalsChand useresultChto send back if withdrawal succeeded - in
Withdrawal(), receive if the withdrawal succeeded
The full solution is given in the README.md file in bank4_channels.
Key takeaways¶
Channels: The best way to structure concurrency in Go!
→ multiplexing (select) allows complex communication with multiple
channels
types of channels:
- unbuffered → synchronization
- buffered → decoupling sender/receiver
- unidirectional → safer APIs
Looking ahead¶
case studies with concurrency:
- caching function calls
- building a chat server