사람이 자신의 모국어를 쓰는건 그 나라에서 다른 사람들과 의사소통하기 위해서이다.
= 주어진 상황에서 사람과 의사소통 할 수 있는 최선의 언어
프로그래밍 언어도 동일하다고 생각한다. 어떤 주어진 상황에서 최선의 언어와 조건을 선택해야하기에, 언어의 특징과 그 이유를 잘 알아야한다고 생각한다.
이번에는 GO언어를 사용하는 큰 이유 중 하나인, 동시성-병렬성, 그리고 쓰레드에 관련한 글을 쓰려 한다.
먼저 각 언어의 개념부터 알아보자!
동시성(Concurrency) vs 병렬성(Parallelism)
동시성은 큰 Task들이 빠르게 전환하여 실행되면서, 동시에 처리하는 것처럼 보이는 것을 말한다.(논리적 개념)
병렬성은 실제로 task들을 동시에 처리하는 것을 말한다. (물리적 개념)
이렇게 놓고 보면 당연히 성능 측면에서는 병렬적으로 처리하는 것이 좋아보이지만, 상황에 따라 더 좋은 것을 선택해야한다.
병렬성은 그만큼 컴퓨팅 자원을 많이 할당해야하고, 동시성은 여러 task를 왔다갔다하면서 가지는 공유 자원 관리가 문제다.
쓰레드(Thread)
쓰레드는 프로세스가 가지는 task를 작은 단위로 나뉘어 실행되는, 여러 흐름의 단위이다.
프로세스에 할당되는 컴퓨팅 자원을 thread끼리 공유하고, stack공간을 프로세스에 할당받고 task를 처리한다.
특히 운영체제가 cpu에 thread를 할당한다!
이는 하나의 프로세스마다 하나의 싱글 thread를 가지며, 프로세스가 가진 컴퓨팅 자원을 고려해서 thread를 더 생성할 수 있다.
자자 그러면 예시로 브라우저에서 여러 탭을 띄운다고 가정하자.
이때 기존에는 브라우저 단일 process위에서 실행되고, 각 탭마다 thread를 할당하는 것이 보편적입니다.
근데 chrome 이놈은 탭마다 자체 프로세스를 가지기에 컴퓨팅 자원을 여간 빼먹는게 아닙니다...
예시로 thread에 대해서 조금 이해를 더했습니다.
이를 처음 접하면 이런 생각을 해볼겁니다.
그럼 최소한의 단위로 task를 분리하고, 한 프로그램마다 단일process 쓰면서 thread를 분리한 task만큼 만들면 되는거 아닌가?
허울 좋은 소리에 불과합니다.
컴퓨팅 자원은 늘 유한하고, 우리(회사나 나)에게 주어진 자원은 더욱 한정적입니다. 때로는 내게 주어진 상황에 따라 어느때는 안정성을, 어느때는 속도를 고려해야하기에 다양한 기술의 이해와 접목이 필요합니다.
thread는 공유자원의 문제와 스피닝과 락을 획득하는 작업에서는 온전한 system call과 커널간의 상호작용이 필요하기에 비용이 많이 들고 있습니다. 이에 요즘 thead는 2종류로 분류되는데, green thread와 native thread로 분리됩니다.
Native thread: 커널 레벨에서 처리되는 thread
Green thread: 유저 레벨에서 처리되는 thread. OS보다 위에서 관리되는거라 생각하자.
결국 Green thread라는 새로운 개념이 등장하면서 context switch가 발생하는 위치를 더 세밀하게 제어할 수 있게 됩니다.
가장 중요한 이 Go언어의 장점이 여기서 등장하는데,
Go의 threading은 M(green):N(Native)으로 알려진 페러다임의 유사한 고유 개념을 이용해 하이브리드 스레딩을 하는 것이 특징이다.
💡 하이브리드 스레딩(hybrid threading)은 커널 레벨과 유저 레벨 코드를 모두 수정해야 해서 더욱 복잡하지만, 다행히 대부분의 최신 커널에는 필요한 모든 프로비전(provisions)이 이미 내장되어 있습니다. 그리고 Go는 그린 스레드를 처리할 수 있는 유저 레벨의 좋은 소프트웨어를 제공하고 있습니다!
고투틴
func squareIt(x int) {
fmt.Println(x * x)
}
func main() {
go squareIt(2) // 고루틴
time.Sleep(1 * time.Millisecond)
}
자. 이제 thread로 사용하는 이유를 알았으니까, 이를 실질적으로 작성하는 코드를 써보자.
squareIt()함수가 있다. 이게 만약 정말 오래 걸리는 작업이라면 main함수에서는 squareIt()함수가 끝날때까지 모든 작업을 멈추고 기다려야한다.... 말이 되는가
그래서 별도의 thread를 마련하고자 go에서는 고루틴을 도입한다.
가벼운 thread를 이용하기 위해 고루틴이라는 개념을 도입한다. 이는 green thread이며, native thread에 비해 고루틴은 500배 이상 가볍다는 특징이 있다. 이에 코어갯수보다 훨씬 많은 고루틴을 만들 수 있다!
위 예시처럼 go를 함수앞에 사용하여 새로운 thread를 생성해준다. 이때 time.Sleep()문이 없다면 4라는 수가 출력되지 않는다!
❓왜일까❓
처음 프로그램을 실행하면 main함수에 대한 고루틴이 생성되는데, 이때 고루틴을 이용해 별도의 고루틴 하나를 더 생성하면서 동시에 작업을 수행한다. 이때 squareIt()에 대한 고루틴이 생기면서 main은 다음 작업을 수행하는데, 만약 sleep()이 없다면 즉시 main이 종료되고, go언어 특성상 main이 종료됨과 동시에 모든 thread가 종료되기에 4를 출력하지 않는다!
자자 고루틴은 이만큼 가볍기때문에 대신 매우 큰 단점이 존재했다. 고루틴이 생성되었을 때 이를 통제할 수단이 없는 것이다.
즉, 고루틴이 서로를 접근해서 생성 및 삭제가 불가능하고, 함수가 어떤 값을 반환(return)하지 못하는 경우가 있습니다.
-> 이를 해결하고자 채널이 탄생합니다.
채널(channel)
채널은 서로간의 소통이 안되는 고루틴끼리 데이터를 주고받을 수 있는 통로, 창구의 역할을 한다.
위의 squareIt()함수를 채널을 이용해서 실제 제곱값을 반환하는 함수로 만들어보자.
func squareIt(inputChan, outputChan chan int) {
for x := range inputChan {
outputChan <- x * x
}
}
위처럼 타입 앞에 chan을 먼저 작성하고 선언하며, 해당 채널에 데이터를 넣고자 한다면 <-를 작성해서 해당 값을 넣는다.
간단해보이지만 채널은 쓰기/읽기 동작에서 접근이 올바르지 못하면 blocking을 하는 경우가 많다.
1. 가득 찬 채널에 쓰기를 시도하는 경우
2. 빈 채널을 읽으려 하는 경우
func squareIt(inputChan, outputChan chan int) {
for x := range inputChan {
outputChan <- x * x
}
}
func main() {
inputChannel := make(chan int)
// outputChannel := make(chan int)
// 해결방법1
outputChannel := make(chan int, 10)
go squareIt(inputChannel, outputChannel)
for i := 0; i < 10; i++ {
inputChannel <- i
}
for i := 0; i < 10; i++ {
// fmt.Println(i)
fmt.Println(<-outputChannel)
}
// 해결방법2
close(inputChannel)
}
해당 함수를 main에서 사용했을 때를 보여준다.
해결방법1: 해결방법1 주석 위의 outputChannel을 사용하면 squareIt 고루틴에 이용된 1값을 가져갈때까지 대기하고
반대로 main고루틴은 2값을 대기하기에 deadlock 현상 발생!!!
그래서 임의로 outputChannel의 버퍼값을 늘려줘서 deadlock문제를 해결한다.
해결방법2: 이후 채널을 닫지 않는다면 InputChannel에서 계속해서 데이터를 대기하기에 이를 해결하려면 채널을 닫아줘야한다.
지금까지 간단하게만 Go언어의 동시성, 고루틴 등에 대해서 알아보았다. 더 공부하면서 추가하고 싶은 내용이 있으면 추가하겠다. 특히 이 블로그가 댓글을 통해서 더더욱 발전하고, 다른 개발자들에게 좋은 지식이 공유될 수 있도록 적극적인 피드백을 부탁드린다.(반존대가 컨셉)
피드백, 충고, 댓글, 조언, 지적 모두 언제나 환영입니다!