티스토리 뷰

Concurrency of Go의 첫 번째, Syntax이다.

Go의 동시성이 짱짱인 이유는 언어 레벨에서 지원하기 때문이야!라고 인트로에서 열심히 주장했으므로,

실제로 그런지 한 번 Go syntax를 상세히 까보자.

역시나 go repo에서 가져온 귀요미

 

* Go 스펙의 전체 내용은 https://golang.org/ref/spec 에서 확인 가능하다

 

소스 코드를 읽고 컴파일하기 위해서는 정해진 규칙들이 있다. 예를 들어 C언어의 경우 세미콜론(;)을 끝에 붙인다던가

그중에서 Go의 동시성에 관한 문법들에는 다음과 같은 것들이 있다.

- Go statements

- Channel types

- Send statements

- Receive operator

- Select statements

- For statements with range clause

 

한 개씩 살펴보자. 

Go statements.

A "go" statement starts the execution of a function call as an independent concurrent thread of control, or goroutine, within the same address space.

(ref. https://golang.org/ref/spec#Go_statements)

 

다음과 같이 go 키워드를 통해 function call을 하게 되면, Goroutine (고루틴)이라고 불리는 동일한 주소 공간 내의 독립적인 스레드에서 함수 호출을 실행하게 된다. 

go Server()
go func(ch chan<- bool) { for { sleep(10); ch <- true }} (c)

이후에 깊게 설명하겠지만, 고루틴은 Go언어의 스레드라고 생각해두자.

즉 go 키워드를 통해 실행되는 함수 호출은 새로운 고루틴을 할당받아 기존 흐름과 동시에 실행되게 된다.

 

2번째 라인과 같은 lambda expression을 지원하고, 이러한 익명 함수는 호출 상태와 바인드 되는 closure이다.

Go는 function type도 지원하므로 예제에는 없지만, 만들어둔 closure를 고루틴으로 호출하는 것도 물론 가능하다.

Channl types

A channel provides a mechanism for concurrently executing functions to communicate by sending and receiving values of a specified element type.

(https://golang.org/ref/spec#Channel_types)

chan T          // can be used to send and receive values of type T
chan<- float64  // can only be used to send float64s
<-chan int      // can only be used to receive ints

채널은 고루틴 간의 특정 타입의 데이터 주고받음을 지원하는 하나의 타입이다! (int, string과 같이 타입의 한 종류)

문자 그대로 데이터 주고받는 '채널'이라 생각하자. (어떻게 주고받는지는 뒤에 이어진다)

chan T 형태로 정의하는데, 예를 들어 chan int는 int형을 주고받을 수 있는 채널이라는 뜻이다.

기본적으로 양방향이지만, <- 키워드를 추가로 붙여 send-only, receive-only로 단방향으로 생성할 수 있다.

 

채널을 생성할 때는 built-in 함수인 make를 사용하고, 채널을 닫을 때는 마찬가지로 built-in함수 close를 사용한다.

make(chan int, 100)
close(c1)

 

생성 시에 버퍼 크기(capacity)를 make의 optional argument로 줄 수 있다.

이 버퍼는 채널이 마치 큐(queue)와 같이 동작하게끔 하는데,

- 버퍼가 꽉 찬 경우 송신 측에서 block 되고, (보낼 자리가 없음)

- 버퍼가 없는 경우 수신 측에서 block 된다. (읽을 데이터가 없음)

 

Send statements

A send statement sends a value on a channel.

(https://golang.org/ref/spec#Send_statements)

ch <- 3  // send value 3 to channel ch

앞서 언급한 채널 타입의 변수에 데이터를 보낸다.

매우 직관적으로 <- 키워드를 사용하는데, 위는 ch라는 채널에 값 3을 보내는 코드이다.

 

당연히 보내는 데이터의 타입과 채널의 타입이 일치(혹은 변환 가능)해야 한다. int 채널에 string 값을 보낼 순 없고, 이러한 것들은 컴파일 타임에 에러로 결정되게 된다.

그리고 앞서 send-only로 선언된 채널에는 보낼 수 없고, 마찬가지로 컴파일 에러다

이미 close 된 채널에 보내게 되면, 런타임 패닉이 발생한다.

 

채널 타입에서 적은 것과 같이, 버퍼에 빈 공간이 없다면 위의 statement는 버퍼에 공간이 생기기 전까지 block 된다.

Receive operator

For an operand ch of channel type, the value of the receive operation <-ch is the value received from the channel ch. 

(https://golang.org/ref/spec#Receive_operator)

v1 := <-ch
v2 = <-ch
f(<-ch)
<-strobe  // wait until clock pulse and discard received value

 

데이터를 보냈으면 받아야 하는데, 이 역시 <-를 쓰되 이번엔 채널의 왼쪽에 적어준다.

첫 번째 라인의 := 키워드는 go의 short variable declartion을 의미한다. (예를 들어 v1 := "abc"라는 코드의 의미는 string 타입의 v1 변수를 선언하고, "abc"로 초기화함을 의미한다.)

우리의 예시는 ch로부터 값을 받아 이에 맞게 v1 변수를 선언하고 그 값으로 초기화하는 것이다.

 

Go의 채널 타입은 first-class object이므로 함수의 인자로도 사용 가능하다.

 

송신과 동일하게 send-only 채널에는 보낼 수 없고,  버퍼에 데이터가 없으면, receive는 다른 곳에서 데이터를 채널에 보내기 전까지 block 된다. 

단 수신 중인 채널이 close 되면 zero value(해당 자료형의 기본값 int는 0, string은 "" 등)가 반환되고, 이미 close 된 채널도 역시 항상 zero value가 반환된다. 당연히 반환 값이 있으므로 block 되지 않는다.

 

receive operator의 경우 아래처럼 boolean 값을 하나 더 받을 수 있다.

x, ok = <-ch
x, ok := <-ch
var x, ok = <-ch
var x, ok T = <-ch

위의 모든 코드는 ok가 없는 것과 동일하게 동작한다.

채널에서 성공적으로 데이터를 받아온 경우 ok는 true가 되고, 앞서 언급한 zero-value가 반환되는 경우에는 false가 반환된다.

 

현재까지 채널에 관한 것들을 요약해보면 다음과 같다

ch := make(chan int, 100)
ch <- 123
v1 <- ch

int형 채널을 만들고, 123을 보내고 v1에 값을 받는다. 매우 직관적인 문법이다.

 

Select statements

A "select" statement chooses which of a set of possible send or receive operations will proceed. It looks similar to a "switch" statement but with the cases all referring to communication operations.

(https://golang.org/ref/spec#Select_statements)

var a []int
var c, c1, c2, c3, c4 chan int
var i1, i2 int
select {
case i1 = <-c1:
	print("received ", i1, " from c1\n")
case c2 <- i2:
	print("sent ", i2, " to c2\n")
case i3, ok := (<-c3):  // same as: i3, ok := <-c3
	if ok {
		print("received ", i3, " from c3\n")
	} else {
		print("c3 is closed\n")
	}
case a[f()] = <-c4:
	// same as:
	// case t := <-c4
	//	a[f()] = t
default:
	print("no communication\n")
}

for {  // send random sequence of bits to c
	select {
	case c <- 0:  // note: no statement, no fallthrough, no folding of cases
	case c <- 1:
	}
}

select {}  // block forever

Go동시성의 꽃 중 하나라고 생각되는 select이다. Concurrency를 "잘" 지원한다는 말이 여기서 한 번 체감할 수 있다.

select는 switch와 유사하다고 생각하면 좋다.

코드 순서대로 case 문의 채널을 1번씩 평가하고 (즉 수신/송신 가능한지) 그중에서 해당되는 한 곳을 실행한다.

이때 해당되는 곳이 없으면 block 되거나 default문이 있으면 default로 이동한다. 만약 실행 가능한 case가 여러 개라면 그중 하나를 균등한 확률로 랜덤 하게 선택한다.

 

위의 코드가 select가 가진 기능들을 전부 잘 표현했는데, case by case로 살펴보자

 

1. receive

case i1 = <-c1:
	print("received ", i1, " from c1\n")

채널 c1에서 값을 받아 i1 변수에 대입하고, 아래 print 문을 실행한다

 

다음과 같이 값을 대입하지 않아도 동일하게 동작한다.

case <-c1:
	print("received from c1\n")

 

2. send

case c2 <- i2:
	print("sent ", i2, " to c2\n")

i2 변수의 값을 c2 채널에 보내고, 아래 print 문을 실행한다

 

3. receie & short variable declaration

case i3, ok := (<-c3):  // same as: i3, ok := <-c3
	if ok {
		print("received ", i3, " from c3\n")
	} else {
		print("c3 is closed\n")
	}

i3 변수를 선언하고 채널 c3로부터 값을 받아와 이 값으로 초기화한다.

만약 채널 c3가 닫힌 경우라면 i3에는 zero-value가, ok에는 false가 대입된다.

이후 print문을 실행한다

 

4. receive

case a[f()] = <-c4:
	// same as:
	// case t := <-c4
	//	a[f()] = t

좌측을 먼저 계산하고 우측의 채널 수신을 한다. 즉 f()를 실행한 후에 채널 c4로부터 값을 받아와 a[f()]에 대입한다,

 

이 4번 케이스가 1번 케이스와 다른 이유는, case 문이 선택되는 시점에 좌측이 evaluation이 된다는 것이다.

즉 채널 c4로부터 데이터가 들어와 이 case문이 선택되기 전에는 f()는 호출되지 않음을 의미한다.

 

5. default

default:
	print("no communication\n")

위의 모든 communication case에 해당할 수 없으면, 해당되는 case가 생길 때까지 block 된다.

하지만 default문이 존재한다면 default문을 실행하게 된다.

 

 

이러한 select는 다음과 같이 for loop와 함께 사용이 가능하다

for {  // send random sequence of bits to c
	select {
	case c <- 0:  // note: no statement, no fallthrough, no folding of cases
	case c <- 1:
	}
}

앞서 선택 가능한 case가 2개 이상인 경우 하나를 랜덤 하게 선택한다고 했으니,

이 경우 채널 c에 0 또는 1을 보내는 무한루프를 돌게 된다.

 

선택될 수 있는 case문이 없으면 영원히 block 된다. 마찬가지로 nil (Go의 null과 유사한것) 채널을 receive하는 경우도 block된다.

select {}  // block forever

 

잠깐 select문을 요약하면

1. send/receive가 가능한 채널을 확인한다

2. 있으면 랜덤 하게 하나 택해서 case문 실행

3. 없으면 block 되거나 default문 실행

(추후에 다루겠지만, 잠깐 이 문법이 Go 동시성의 꽃이 되는 이유를 생각해보면 좋을 것 같다)

 

For statements with range clause

A "for" statement with a "range" clause iterates through all entries of an array, slice, string or map, or values received on a channel. For each entry it assigns iteration values to corresponding iteration variables if present and then executes the block. (...) 

(https://golang.org/ref/spec#For_statements)

var ch chan Work = producer()
for w := range ch {
	doWork(w)
}

// empty a channel
for range ch {}

흔히 아는 for 루프와 동일하다. 차이점은 range 뒤에 일반적인 array와 같은 반복자(iterator)뿐만 아니라 채널도 올 수 있다는 것이다!

 

일반적인 반복자의 next(), hasNext()로 생각하면 잘 와 닿을 것이다.

앞서 채널은 큐와 유사한 자료구조라고 했던 것을 생각하면 된다.

  Iterator Channel
next() 다음 원소 버퍼의 다음 값
hasNext() 다음 원소가 있는지 버퍼에 원소가 있는지

다만, 버퍼가 비어있으면 loop를 돌지는 않지만, 그렇다고 for 문이 종료되지는 않는다.

for 문은 주어진 채널이 close 되거나 명시적으로 return, break와 같은 jump statement가 있는 경우에만 종료된다.

 

단, receive opeator와는 다르게 채널 상태 boolean 값을 같이 받을 수는 없다. 즉 아래는 구문 오류이다.

이미 for문의 lifetime이 채널 상태로 결정된 시점에서 굳이 이 값을 허용할 필요가 없다고 생각한 듯하다.

for w, ok := range ch {
	doWork(w)
}

 

세 줄 요약

고루틴의 관리는 Go 런타임이 담당하고, 개발자는 그저 go.

개발자는 이 채널의 사용에, 즉 실제 동시성 로직 구현에 집중하도록 채널에 관한 문법들이 다수 존재한다.

특히 select 문은 specification에는 언급이 안되었지만 수많은 go concurrency pattern의 핵심이 되고, context, time 등과 같은 다양한 패키지들을 통해 수많은 사용처가 존재한 중요한 syntax라고 한번 더 강조하고 싶다.

 

'Let it GO > Go 동시성' 카테고리의 다른 글

Let it GO. Go의 동시성 - Goroutines (1)  (1) 2019.11.10
Let it GO. Go의 동시성  (0) 2019.11.06
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함