Share
Embed
Transcript
40 slides
-
(Not Very) Practical Concurrent Design Patterns in Go Nov 25,
2018 Moriyoshi Koizumi Open Collector, Inc. -
Agenda Synchronization Basics Synchronizing Access to Resources in Go Who
am I: @moriyoshi at github.com / @moriyoshit at twitter.com. Early Go contributor. Reviewed the Japanese translation of "Concurrency in Go". -
Note on the term usage To generally discuss synchronization things
at the same level of abstraction, here we use the term processes instead of goroutines. Note it does not refer to the more concrete concept like OS processes. -
Processes A process is a series of operations accompanied by
zero or any number of control sequences, which may be cyclic. Op1 Op2 Op3 A process is to be started by another process (except for the very rst process). -
Processes (cont'd) A process may run simultaneously and independently from
other processes. ` Time -
Resources Resource are what processes interact and have side e
ects on (reading, writing etc.). A resource is usually a region of physical memory or an OS's in-kernel object exposed to the user-land as a handle (namely, a le descriptor). ` ` ` File File fd:3 fd:2 File File Memory Kernel Space -
Race Conditions Processes run concurrently, or maybe in parallel. What
if more than one process operate on the same resource at the same time? Ends up leaving the resource in an inconsistent state. Time Overlap ?! A B C A B C Op. Op. Op. -
Locks A mechanism to keep multiple processes from operating on
the same resource at once (once in a concurrent manner). The part of operation sequence of which a lock prevents concurrent execution is called a critical section. Critical sections racing for a resource will be serialized by applying the lock at the entry and leaving point of each critical section, thus those are mutually exclusive. Time A B C A B Op. Op. Op. C Blocked Locked Unlocked Locked Unlocked Locked Unlocked -
Locks (cont'd) A most primitive lock can be represented as
an object that has a binary state, locked and unlocked, and allows only one process to get it locked, by blocking the execution of other contending processes. In Go, these properties are provided by sync.Mutex: var lock sync.Mutex // Waiter go func() { lock.Lock() fmt.Println("I waited too long!") lock.Unlock() }() // First set the lock to locked lock.Lock() // Sleep enough for the waiter to launch time.Sleep(time.Millisecond) // Signal the lock lock.Unlock() Run -
Counting Semaphore Is a synchronization primitive that has a counting
variable which holds an integer value. de nes a set of operations: signal, which atomically increments the counting variable by n and awakes one of the waiters if it reaches zero. wait, which atomically decrements the counting variable by one and lets the operating process sleep until the counting variable becomes zero. A counting semaphore can be composed of two locks: A lock that ensures the atomicity of the operations. A lock that signals one of the waiters. -
Counting Semaphore (cont'd) package main import ( "fmt" "sync" "time"
) type CountingSemaphore struct { c int mu sync.Mutex s sync.Mutex } func (cs *CountingSemaphore) Signal() { cs.mu.Lock() cs.c++ if cs.c == 0 { cs.s.Unlock() } cs.mu.Unlock() } func (cs *CountingSemaphore) Wait() { cs.mu.Lock() if cs.c >= 0 { cs.c-- } c := cs.c -
cs.mu.Unlock() if c < 0 { cs.s.Lock() } } func
main() { cs := CountingSemaphore{} go func() { i := 0 for { cs.Wait() fmt.Println("hey", i) i += 1 time.Sleep(time.Millisecond) } }() time.Sleep(time.Millisecond) for { cs.Signal() cs.Signal() cs.Signal() time.Sleep(time.Second) } } Run -
Producer-Consumer Problem Settings: There are processes of two ends of
a communication, a producer and a consumer. The producer generates data and pass it to the consumer through a FIFO bu er. The consumer receives the generated data from the bu er. The length of the FIFO bu er is limited. Problem: How can we make producer wait for the space if the bu er is full, while making consumer wait for the availability of the data? Producer Consumer Producer Consumer (wait) (wait) FIFO is empty FIFO is full -
Producer-Consumer Problem (cont'd) Solution: Wikipedia: prepare two counting semaphores, one
for signaling vacancy, having its counter initialized to the size of the bu er. one for signaling availability, having its counter initialized to zero. Go: Just use channels. -
Reader-Writer Problem Settings: There are many waiters that read the
resource (readers) and a few waiters that modify it (writers). Readers don't actually need a lock to access to the resource, but need writers kept away from the resource during the read. A writer must be protected from other writes, too. Problem: If a single mutex is applied to both readers and writers for avoiding the race, it wouldn't perform well because writes tend to take longer than the read. How can we improve the performance? -
Reader-Writer Problem (cont'd) Solution: Wikipedia: split the lock into a
reader lock and a writer lock for ner granularity of locks. The reader lock, accompanied by the variable that counts the readers, ensures the atomicity of the counting operation. The writer lock, used to signal the waiter. Go: Just use sync.RWMutex. -
Monitors and Condition Variables Monitor is a concept of synchronized
operations against a resource, where condition variables are key synchronization constructs. Condition variables de ne a following set of operations: wait, which lets the process sleep until the condition variable is signaled. signal, which signals the condition variable to awake any single waiter. broadcast, which signals the condition variable to awake all the waiters. Proposed in a 1973 paper by Per Brinch Hansen, and formalized by Charles Antony Richard Hoare in 1974. Originally posed as an architecture for general resource management in OS. -
Monitors and Condition Variables (cont'd) Di erences from semaphores: signal
operations aren't backlogged. If no waiters are present, they'll be simply ignored. No broadcast operation is de ned in semaphores. signal with the number of waiters can simulate it. Two Di erent Semantics: Mesa semantics: Adopted by Go, Java, C++, pthread ... many Hoare semantics: Only in textbooks? -
Monitors and Condition Variables (cont'd) In Go, wait and signal
may be represented by a (bu ered) channel and select. c := make(chan struct{}) // -- waiter <-c // -- signaler select { case c <- struct{}{} default: } -
Monitors and Condition Variables (cont'd) In some cases, wait and
broadcast may be represented by a channel and close(). c := make(chan struct{}) // -- waiter <-c // ... checking condition ... // -- signaler close(c) Note that the channel becomes unreusable after being signaled. -
Monitors and Condition Variables (cont'd) Use sync.Cond import "sync" mu
:= &sync.Mutex{} cond := sync.NewCond(mu) // -- waiter func() { L.Lock() defer L.Unlock() cond.Wait() // ... checking condition ... }() // -- signaler cond.Signal() cond.Broadcast() -
Monitors and Condition Variables (cont'd) An excerpt from src/sync/cond.go: //
Wait atomically unlocks c.L and suspends execution // of the calling goroutine. After later resuming execution, // Wait locks c.L before returning. Unlike in other systems, // Wait cannot return unless awoken by Broadcast or Signal. // // Because c.L is not locked when Wait first resumes, the caller // typically cannot assume that the condition is true when // Wait returns. Instead, the caller should Wait in a loop: // // c.L.Lock() // for !condition() { // c.Wait() // } // ... make use of condition ... // c.L.Unlock() // func (c *Cond) Wait() { c.checker.check() t := runtime_notifyListAdd(&c.notify) c.L.Unlock() runtime_notifyListWait(&c.notify, t) c.L.Lock() } -
Ideas Use synchronization primitives / constructs Let goroutines restrain themselves
from making concurrent access to the resource in automony. Use arbitrating goroutines Let goroutines delegate the operations to the arbitrator goroutine tied to the resource one-by-one. Like a client-server model in distributed computing. Arbitrator -
Example: access to a shared variable Using mutexes: package main
import ( "fmt" "sync" ) type Foo struct { v1 int v2 string mu sync.Mutex } func (f *Foo) GetV1() int { f.mu.Lock() defer f.mu.Unlock() return f.v1 } func (f *Foo) SetV1(v int) { f.mu.Lock() defer f.mu.Unlock() f.v1 = v } func (f *Foo) GetV2() string { f.mu.Lock() -
defer f.mu.Unlock() return f.v2 } func (f *Foo) SetV2(v string)
{ f.mu.Lock() defer f.mu.Unlock() f.v2 = v } func main() { f := Foo{} f.SetV1(123) fmt.Println(f.GetV1()) f.SetV2("test") fmt.Println(f.GetV2()) } Run -
Example: access to a shared variable (cont'd) De ne a
mutex in the struct that contains shared variables in question: type Foo struct { v1 int v2 string mu sync.Mutex } Surround the accessing function body with Lock() and Unlock(). func (f *Foo) GetV1() int { f.mu.Lock() defer f.mu.Unlock() return f.v1 } func (f *Foo) SetV1(v int) { f.mu.Lock() defer f.mu.Unlock() f.v1 = v } -
Example: access to a shared variable (cont'd) Using arbitrators: package
main import ( "fmt" ) type Foo struct { v1 int v2 string finChan chan struct{} v1GetChan chan chan int v1SetChan chan int v2GetChan chan chan string v2SetChan chan string } func (f *Foo) GetV1() int { ch := make(chan int) f.v1GetChan <- ch return <-ch } func (f *Foo) SetV1(v int) { f.v1SetChan <- v } func (f *Foo) GetV2() string { -
ch := make(chan string) f.v2GetChan <- ch return <-ch }
func (f *Foo) SetV2(v string) { f.v2SetChan <- v } func (f *Foo) Dispose() { close(f.finChan) } func NewFoo() *Foo { f := &Foo{ v1: 0, v2: "", finChan: make(chan struct{}), v1GetChan: make(chan chan int), v1SetChan: make(chan int), v2GetChan: make(chan chan string), v2SetChan: make(chan string), } go func() { outer: for { select { case <-f.finChan: break outer case c := <-f.v1GetChan: c <- f.v1 case v := <-f.v1SetChan: -
f.v1 = v case c := <-f.v2GetChan: c <- f.v2
case v := <-f.v2SetChan: f.v2 = v } } }() return f } func main() { f := NewFoo() defer f.Dispose() f.SetV1(123) fmt.Println(f.GetV1()) f.SetV2("test") fmt.Println(f.GetV2()) } Run -
Example: access to a shared variable (cont'd) Prepare channels for
each operation. type Foo struct { v1 int v2 string finChan chan struct{} v1GetChan chan chan int v1SetChan chan int v2GetChan chan chan string v2SetChan chan string } Launch a proxying goroutine in the factory function. func NewFoo() *Foo { f := &Foo{ v1: 0, v2: "", finChan: make(chan struct{}), v1GetChan: make(chan chan int), v1SetChan: make(chan int), v2GetChan: make(chan chan string), v2SetChan: make(chan string), } go func() { outer: for { select { -
case <-f.finChan: break outer case c := <-f.v1GetChan: c <-
f.v1 case v := <-f.v1SetChan: f.v1 = v case c := <-f.v2GetChan: c <- f.v2 case v := <-f.v2SetChan: f.v2 = v } } }() return f } -
Example: access to a shared variable (cont'd) Wrap the send
to each channel by accessor methods. func (f *Foo) GetV1() int { ch := make(chan int) f.v1GetChan <- ch return <-ch } func (f *Foo) SetV1(v int) { f.v1SetChan <- v } Creation of the shared data and disposal f := NewFoo() defer f.Dispose() -
Pros / Cons Synchronization constructs: Pros: Fast. No explicit launch
of goroutine is needed. Clean-up is also unnecessary. Cons: Does not play well with context.Context or time.Timer. Cancellation is signaled through a channel. Technically, Go runtime is capable of polling channels and sync.Mutex's in a single select construct... -
Pros / Cons (cont.d) Arbitrators: Pros: Make it look more
Go-like. Play well with other components that utilize channels to asynchronous communication. → If simply using synchronization constructs, a short-living goroutine might need to be launched at each rendevous point. Cons: Codes tend to be bloated. Explicit launch of goroutine is needed. Clean-up is necessary to avoid goroutine leaks. -
Conclusion Synchronization constructs are composed from more primitive constructs. Go's
builtin inter-process communication construct is actually built from such building blocks. Higher-level constructs can emulate low-level constructs. In Go, there are a wide range of choices for synchronization. Learn the various patterns and the semantics behind them, and choose the approriate means. -
Thank you Nov 25, 2018 Tags: golang, sync, concurrency (#ZgotmplZ)
Moriyoshi Koizumi Open Collector, Inc. [email protected] (mailto:[email protected]) http://www.mozo.jp/ (http://www.mozo.jp/) @moriyoshit (http://twitter.com/moriyoshit)