ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 9장 공유 변수를 이용한 동시성
    프로그래밍/Golang 2019. 11. 20. 22:52
    The Go Programming language

    개요

    동시성(Concurrency) 메커니즘 설명

    변수를 공유할 때 문제점, 분석방법, 해결법의 패턴 설명

    고루틴과 운영체제 스레드 간의 기술적 차이점 설명

    9.1 경쟁 상태

    한 이벤트가 다른 이벤트보다 멎저 일어난다고 확인할 수 없을 때 이벤트 x와 y가 동시(concurrent)에 일어난다고 한다.

    한 함수를 두 개 이상의 고루틴에서 부가적인 동기화 과정 없이 동시에 호출해도 제대로 동작하면 동시성에 안전(concurrency-safe)하다고 한다.

    대부분의 변수는 단일 고루틴으로 제한하거나 상위 수준의 상호 배제 불변성을 통해 동시 접근을 방지한다.

    함수를 동시에 호출할 수 없는 원인은 경쟁 상태(race condition), 교착상태(dead lock, live lock), 자원고갈 등이 있다.

    경쟁 상태는 프로그램이 여러 고루틴의 작업 간 간섭으로 인해 올바른 결과를 반환하지 못하는 상태를 말한다.

    경쟁 상태는 프로그램에 숨어 있다가 높은 부하, 특정 컴파일러, 플랫폼, 아키텍처 등의 특별한 경우에만 가끔 나타나기 때문에 매우 위험하다.

    // Package bank implements a bank with only one account. package bank
    var balance int
    func Deposit(amount int) { balance = balance + amount } 
    func Balance() int { return balance }

    위 함수들을 순서대로가 아니라 동시에 호출한다면 Balance는 더 이상 올바른 결과를 보장할 수 없게 된다.

    // Alice:
    go func() {
    	bank.Deposit(200) // A1
    	fmt.Println("=", bank.Balance()) // A2 
    }()
    
    // Bob:
    go bank.Deposit(100) // B

    "앨리스 먼저", "밥 먼저", "앨리스/밥/앨리스"의 3가지 순서만 있다고 생각할 수 있다.

    Alice first 	Bob first 	Alice/Bob/Alice 
    	0	0		0
    A1 200 		B 100		A1 200						 
    A2 "= 200"	A1 300 		B 300	
    B 300	 	A2 "= 300"	A2 "= 300"

    어떤 경우에도 최종 잔액은 300이다.

    문제가 발생하는 네번째 경우가 있다. 

    앨리스가 예금하는 시이 잔고를 읽고(balance + amount) 갱신하기 전(balance = ...)에 밥이 예금해서 밥의 거래 내역이 사라질 때다. 이러한 문제를 간섭 현상이라 한다.

    Data race 
    	0
    A1r 0		... = balance + amount
    B 100
    A1w 200		balance = ...
    A2 "= 200"

    최종 잔액은 200이다.

    데이터 경쟁(data race)이라는 특정한 경쟁 조건이 포함돼 있다.

    데이터 경쟁은 두 고루틴이 동시에 같은 변수에 접근하고, 이 중에 적어도 한 접근에서 갱신이 일어날 때 발생한다.

     

    데이터 경쟁 회피 방법은 3가지가 있다.

    첫 번째는 변수를 갱신하지 않는 것이다.

    두 번째는 여러 고루틴에서 변수 접근을 피하는 것이다. 다른 고루틴은 변수에 직접 접근할 수 없기 때문에 변수를 조회하거나 갱신하려면 변수를 갖고 있는 고루틴에게 채널로 요청해야 한다. 이것이 Go의 슬로건인 "메모리 공유로 통신하지 말라. 대신 통신으로 메모리를 공유하라"의 의미다. 채널 요청으로 제한된 변수로 접근을 중개하는 고루틴을 해당 변수의 관리 고루틴이라 한다.

    gopl.io/ch9/bank1
    	// Package bank provides a concurrency-safe bank with one account.
    	package bank
    	var deposits = make(chan int) // send amount to deposit
      	var balances = make(chan int) // receive balance
      	func Deposit(amount int) { deposits <- amount }
      	func Balance() int { return <-balances }
        
     	func teller() {
        	var balance int // balance is confined to teller goroutine
        	for {
            	select {
                case amount := <-deposits:
    				balance += amount 
                case balances <- balance: 
                }
            } 
        }
        func init() {
        	go teller() // start the monitor goroutine
    }
    

    변수는 파이프라인의 한 단계에 국한되며 다음 단계에서 반복되는 식이다. 이 방식을 직렬 제한(serial confinement)이라 하기도 한다.

    다음은 Cakes는 처음에는 baker 고루틴에 직렬 제한되고, 그 다음엔 icer 고루틴에 직렬 제한된다.

    type Cake struct{ state string }
    func baker(cooked chan<- *Cake) { 
    	for {
    		cake := new(Cake)
    		cake.state = "cooked"
    		cooked <- cake // baker never touches this cake again
    	} 
    }
    func icer(iced chan<- *Cake, cooked <-chan *Cake) { 
    	for cake := range cooked {
    		cake.state = "iced"
    		iced <- cake // icer never touches this cake again 
        }
    }

    세 번째 방법은 여러 고루틴에서 변수 접근은 허용하지만 한 번에 하나씩만 접근하는 것이다. 이러한 접근 방식을 상호 배제(mutual exclusion)라 한다.

     

    9.2 상호 배제: sync.Mutex

    용량이 1인 채널을 사용해 최대 한 개의 고루틴만 공유 변수에 접근하게 할 수 있다. 1까지만 세는 세마포어를 이진 세마포어(binary semaphore)라 한다.

    gopl.io/ch9/bank2
    var (
    	sema = make(chan struct{}, 1) // a binary semaphore guarding balance 
        balance int
    )
    func Deposit(amount int) {
    	sema <- struct{}{} // acquire token 
        balance = balance + amount
    	<-sema // release token
    }
    func Balance() int {
    	sema <- struct{}{} // acquire token 
        b := balance
    	<-sema // release token
    	return b
    }

    위 상호 배제는 sync 패키지의 Mutex 타입에서 직접 지원된다. Lock 메소드는 토큰(잠금이라 한다)을 얻고, Unlock 메소드는 잠금을 해제한다.

    gopl.io/ch9/bank3
    	import "sync"
    	var (
    		mu sync.Mutex // guards balance 
            balance int
    	)
    	func Deposit(amount int) { 
        	mu.Lock()
    		balance = balance + amount
    		mu.Unlock() 
        }
    	func Balance() int { 
        	mu.Lock()
    		b := balance 
            mu.Unlock() 
            return b
    	}

    고루틴이 balance 변수에 접근할 때마다 뮤텍스의 Lock 메소드를 호출해 전용 잠금을 얻어야 한다. 다른 고루틴에서 잠금을 획득했다면 이 작업은 다른 고루틴이 Unlock을 호출해 다시 잠금을 얻을 수 있게 될 때까지 대기한다. 이때 뮤텍스가 공유 변수를 보호한다. 관행상 뮤텍스에 의해 보호되는 변수는 뮤텍스 선언 직후에 선언한다. 이 규칙을 어기는 경우는 문서화해야 한다.

    고루틴이 공유 변수를 자유롭게 읽고 변경할 수 있는 Lock과 Unlock 사이의 코드 구간을 임계 영역(critical section)이라 한다.

    함수, 뮤텍스 잠근, 변수를 배열하는 것을 모니터(monitor)라 한다.

     

    Lock과 Unlock 호출이 정확하게 한 쌍을 이루고 있는지 확인하기 어렵다. 이때 Go의 defer 구문이 도움이 된다. Unlock 호출을 연기하면 임계 영역이 묵시적으로 현재 함수의 마지막까지 연장되므로 Lock 호출에서 멀리 떨어진 여러 위치에서 Unlock을 기억해 뒀다가 호출하지 않아도 된다.

    func Balance() int { 
    	mu.Lock()
    	defer mu.Unlock()
        return balance
    }

    캡슐화(6.6절)는 프로그램의 예기치 않은 상호작용을 줄여서 자료 구조의 불변 값을 유지하게 돕는다. 같은 이유로 캡슐화는 동시성 불변 값을 유지하는 데에도 도움이 된다. 뮤텍스를 사용할 때는 뮤텍스와 뮤텍스로 보호하는 변수가 패키지 수준 변수이든 구조체 필드이든 익스포트되지 않았는지를 확인해야 한다.

    9.3 읽기/쓰기 뮤텍스: sync.RWMutex

    읽기만 하는 작업은 각각 병렬로 접근할 수 있지만 쓰기 작업은 완벽하게 배타적으로 접근해야 하는 특별한 잠금이 필요하다. 이 잠금을 다중 읽기, 단일 쓰기 잠금이라 하며, Go에서는 sync.RWMutex로 제공한다.

    var mu sync.RWMutex 
    var balance int
    func Balance() int { 
    	mu.RLock() // readers lock 
    	defer mu.RUnlock()
    	return balance
    }

    RWMutex는 내부적으로 더 복잡한 기록 과정이 필요하므로 경합이 없는 잠금에서는 일반 뮤텍스보다 느리다.

    9.4 메모리 동기화

    뮤텍스가 필요한 두 가지 이유가 있다.

    첫 번째는 다른 작업 도중에 실행되지 않게 하는 것도 그 반대와 마찬가지로 중요하기 때문이다.

    두 번째 이유는 동기화가 여러 고루틴의 실행 순서를 정하는 것 이상기 때문이다. 즉 동기화는 메모리에도 영향을 미친다.

    9.5 게으른 초기화: sync.Once

    고비용이 드는 초기화 단계는 꼭 필요할 때까지 늦추는 편이 좋다. 변수를 처음부터 초기화하는 것은 프로그램의 시작 시간을 늦추고, 프로그램 실행 시 항상 해당 변수를 사용하는 부분에 도달하지 않는다면 불필요한 작업이 된다.

     

    명시적인 동기화가 없으면 컴파일러와 CPU는 각 고루틴이 일관된 순서로 동작하는 한 그 외의 메모리에 대한 접근 순서를 자유롭게 조정할 수 있다.

     

    sync 패키지에는 일회성 초기화 문제에 대한 특별한 해결책인 sync.Once가 있다. Once는 개념적으로 뮤텍스와 초기화가 완료됐는지를 기록하는 불리언 변수로 이뤄져 있다. 뮤텍스는 불리언 변수와 사용자의 자료 구조를 보호한다. 그 유일한 메소드인 Do는 초기화 함수를 인자로 받는다.

    var loadIconsOnce sync.Once
    var icons map[string]image.Image
    // Concurrency-safe.
    func Icon(name string) image.Image {
    	loadIconsOnce.Do(loadIcons)
    	return icons[name]
    }

    Do(loadIcons)로 각 호출은 뮤텍스를 잠그고 불리언 변수를 확인한다. 변수가 거짓인 첫 번째 호출에서 Do는 loadIcons를 호출한 후 변수를 참으로 설정한다. 이후의 호출은 아무것도 하지 않지만 뮤텍스 동기화로 loadIcons의 결과(즉, icons)를 메모리에 유지해 다른 모든 고루틴에서 볼 수 있게 한다. 이와 같이 sync.Once를 사용해 변수가 제대로 구성될 때까지 다른 고루틴으로 공유를 방지할 수 있다.

     

    9.6 경쟁 상태 검출

    Go 런타임과 도구에는 정교하고 사용하기 쉬운 동적 분석 도구인 경쟁 상태 검출기가 내장돼 있다.

    go build, go run, go test 명령의 뒤에 -race 플래그만 추가하면 된다.

    경쟁 상태 검출기는 이벤트 스트림을 관찰해 한 고루틴에서 최근에 다른 고루틴이 수정한 공유 변수를 중간의 동기화 과정 없이 읽거나 쓰는 경우를 감지한다. 이는 공유 변수로의 동시 접근을 나타내며 데이터 경쟁 상태가 된다.

     

    9.7 예제: 동시 넌블로킹 캐시

     

    9.8 고루틴과 스레드

    9.8.1 가변 스택

    각 OS의 스레드는 고정 크기 스택이다. 크기는 2MB이다.

    고루틴은 가변 크기 스택이다. 일반적으로 2KB인 작은 스택으로 시작한다. 최대 1GB가 가능하다.

     

    9.8.2 고루틴 스케줄링

    OS 스레드는 OS 커널에 의해 스케줄된다. 매 밀리초마다 하드웨어 타이머가 프로세서를 인터셉트해 커널 함수 scheduler가 호출되게 한다. 이 기능은 현재 실행 중인 스레드를 일시적으로 중단하고 메모리의 레지스터를 저장한 후 스레드 목록을 조회해 다음에 수행할 스레드를 결정하고 메모리에서 해당 스레드의 레지스터를 복원한 후 복원된 스레드의 수행을 재개한다. OS 스레드는 커널에 의해 스케줄링되므로 한 스레드에서 다른 스레드로 제어를 넘기려면 한 사용자 스레드의 상태를 메모리에 저장하고 다른 스레드의 상태를 복원한 후 스케줄러의 자료 구조를 갱신하는 전체 컨텍스트 전환이 필요하다. 이 동작은 지역성의 부족과 필요한 메모리 접근 횟수로 인해 느리며 메모리에 접근하기 위해 필요한 CPU 사이클의 수가 증가함에 따라 점점 더 느려지고 있다.

    Go 런타임은 n개의 OS 스레드에 있는 m개의 고루틴을 다중화(또는 스케줄링)하는 m:n 스케줄링 기법의 자체 스케줄러를 포함하고 있다. Go 스케줄러의 역할은 커널 스케줄러와 유사하지만 단일 Go 프로그램의 고루틴에 국한된다.

    Go 스케줄러는 운영체제의 스레드 스케줄러와는 다르게 하드웨어 타이머에 의해 주기적으로 호출되지 않고 특정한 Go 언어의 기반에 의해 묵시적으로 호출된다. 예를 들어 고루틴이 time.Sleep을 호출하거나 채널 또는 뮤텍스 작업을 대기할 때는 스케줄러가 해당 고루틴을 슬립 상태로 만들고 이후 깨워야 할 때까지 다른 고루틴을 실행한다. 이때는 커널 컨텍스트 전환이 필요하지 않으므로 고루틴의 스케줄 재조정이 스레드 스케줄을 재종하는 것보다 훨씬 비용이 적게 든다.

     

    9.8.3 GOMAXPROCS

    Go 스케줄러는 GOMAXPROCS 파라미터를 사용해 동시에 얼마나 많은 OS 스레드에서 Go 코드를 수행할지 결정한다. 기본값은 CPU 갯수이다.

    for {
    	go fmt.Print(0)
    	fmt.Print(1) 
    }
    $ GOMAXPROCS=1 go run hacker-cliché.go 
    111111111111111111110000000000000000000011111...
    $ GOMAXPROCS=2 go run hacker-cliché.go 
    010101010101010101011001100101011010010100110...
    

    9.8.4 고루틴에는 식별자가 없다

    멀티스레딩을 지원하는 대부분의 운영체제 및 프로그래밍 언어에서 현재 스레드에는 일반적인 값인 정수나 포인터로 손쉽게 언을 수 있는 독자적 식별자가 있다. 이는 사실상 스레드 식별자를 키로 갖는 전역 맵인 스레드 로컬 스토리지라는 추상화 계층을 만들기 쉽게 하기 위한 것으로 각 스레드는 이 저장 공간을 통해 다른 스레드와 독립적으로 값을 저장하고 읽을 수 있다.

    고루틴에는 개발자가 접근 가능한 식별자에 대한 표현 방법은 없다. 이는 스레드 로컬 스토리지가 남용되는 경향이 있기 때문에 의도적으로 설계된 것이다.

     

Designed by Tistory.