본문 바로가기

Programming/Theory

동기, 비동기, 블록, 논 블록 개념: Synchornous, Asynchronous, Blocking, Non-Blocking with Go(Golang)

개요

I/O 모델 비교

대부분의 개발자들은 동기(Synchronous)/비동기(Asyncronous)에 대해서는 들어봤을 것이다. 블록(Blocking)/논블록(Non-Blocking) 에 대해서도 아마 들어봤을 수도 있다. 처음엔 동기 + 블록으로 개발을 하다가 성능이 필요해지는 순간 보통 비동기 + 논블록 쪽으로 가게 된다. 요즘에서야 AIO(Asynchronous IO) 가 대중화됐지만 불과 10여 년 전만 해도 새로운 기술이었다. 이 번 포스팅은 간단한 설명과 예제를 통해서 위 4가지의 개념에 대해 설명한다.

Synchronous 와 Asynchronous

동기 방식과 비 동기 방식의 차이를 간단하게 설명하면 동기 방식은 같은 시간 속에서 여러 이벤트가 차례로 수행되는 방식이고, 비 동기 방식은 서로 다른 시간을 갖는 이벤트라고 볼 수 있다.

Blocking 과 Non-Blocking

블록/논 블록은 좀 더 이해하기 쉽다. 진행하고 있는 이벤트가 다른 이벤트를 발생 시킬 때, 블록 방식은 발생한 이벤트를 기다리고, 논 블록 방식은 기다리지 않고 남아있는 작업을 처리 할 수 있게 된다.

Synchronous + Blocking 

가장 일반적인 모델이다. 아무런 기법도 없고, 함수를 호출하고, 해당 함수가 종료까지 기다리게 된다. 아래 간단한 예제를 보자. 1 초를 기다리는 work 라는 함수가 있다. main 은 work 를 호출하고, 시작 시간과 종료 시간을 기록하여 그 차이를 리턴한다. 

package main

import (
	"log"
	"time"
)

func work(i int) {
	time.Sleep(1 * time.Second)
	log.Printf("work %d Done\n", i)
}

func main() {
	start := time.Now()

	work(0)

	log.Printf("take %d milis\n", time.Since(start).Milliseconds())
}

 

위 코드를 실행하면 결과는 아래와 같다. work() 함수가 1초를 쉬고 "work 0 Done" 을 출력한 뒤 끝나기 떄문에 1초가 지나있게 된다.

$ go run hello.go 
2022/07/05 15:22:12 work 0 Done
2022/07/05 15:22:12 take 1000 milis

 

한 번 work() 를 5번 호출해보자

package main

import (
	"log"
	"time"
)

func work(i int) {
	time.Sleep(1 * time.Second)
	log.Printf("work %d Done\n", i)
}

func main() {
	start := time.Now()

	for i := 0; i < 5; i++ {
		work(i)
	}

	log.Printf("take %d milis\n", time.Since(start).Milliseconds())
}

 

1초 간격으로 각 work n Done 을 출력하고 마지막에 5초가 걸렸다는 것을 알 수 있다.

$ go run hello.go 
2022/07/05 15:21:33 work 0 Done
2022/07/05 15:21:34 work 1 Done
2022/07/05 15:21:35 work 2 Done
2022/07/05 15:21:36 work 3 Done
2022/07/05 15:21:37 work 4 Done
2022/07/05 15:21:37 take 5000 milis

 

이처럼 Sync + Blocking 모델은 main 함수가 호출한 순서대로 수행되고, 해당 함수들이 수행될 동안 다른 작업을 할 수가 없다. 단순한 구조를 가지기 떄문에 Concurrency 이슈를 체크하지 않아도 돼서 버그가 발생하기 어렵다. 하지만 호출된 함수가 리턴하기 전까지 호출한 함수는 아무것도 할 수 없으므로 latency 가 매우 커질 수 있다.

Synchronous + Non-Blocking

Sync + Blocking 보다 조금 나은 모델이다. Sync + Non-Blocking 모델을 사용하면 함수 호출하고 기다리는 것이 아니라 일단 함수 호출을 통해서 시작을 시키고 주기적으로 확인을 하는 방법이다.

 

아래 예제를 보자. 이 예제에서는 클로저라는 기법을 사용했는데 golang 에서 기본적으로 제공하는 기능이다. 아래 work 함수의 동작 방법을 이해하지 못하는 사람을 위해 짧게 설명하면, work(i) 통해 리턴된 job 은 work 안의 start 변수를 참조할 수 있다.

 

다시 돌아와서 아래 코드는 job 이라는 함수를 통해서 work 가 끝났는지 주기적으로 확인하고,  끝나면 다음 work를 수행하는 방식이다.

package main

import (
	"log"
	"time"
)

func work(i int) func() bool {
	start := time.Now()
	return func() bool {
		return time.Since(start).Milliseconds() >= 1000
	}
}

func main() {
	start := time.Now()

	for i := 0; i < 5; i++ {
		job := work(i) // start work
		log.Printf("Job Try Other %d\n", i)
		for !job() {
			time.Sleep(500 * time.Millisecond)
			log.Printf("Job %d Try Ohter\n", i)
		}
		log.Printf("Work done %d\n", i)
	}

	log.Printf("take %d milis\n", time.Since(start).Milliseconds())
}

 

결과를 보면 최종 소모된 시간은 Sync + Blocking 모델과 같지만 Work 사이에 Job n Try Other 을 찍는 다른 작업을 하는 것을 볼 수 있다.

$ go run hello.go 
2022/07/05 15:23:15 Job Try Other 0
2022/07/05 15:23:15 Job 0 Try Ohter
2022/07/05 15:23:16 Job 0 Try Ohter
2022/07/05 15:23:16 Work done 0
2022/07/05 15:23:16 Job Try Other 1
2022/07/05 15:23:16 Job 1 Try Ohter
2022/07/05 15:23:17 Job 1 Try Ohter
2022/07/05 15:23:17 Work done 1
2022/07/05 15:23:17 Job Try Other 2
2022/07/05 15:23:17 Job 2 Try Ohter
2022/07/05 15:23:18 Job 2 Try Ohter
2022/07/05 15:23:18 Work done 2
2022/07/05 15:23:18 Job Try Other 3
2022/07/05 15:23:18 Job 3 Try Ohter
2022/07/05 15:23:19 Job 3 Try Ohter
2022/07/05 15:23:19 Work done 3
2022/07/05 15:23:19 Job Try Other 4
2022/07/05 15:23:19 Job 4 Try Ohter
2022/07/05 15:23:20 Job 4 Try Ohter
2022/07/05 15:23:20 Work done 4
2022/07/05 15:23:20 take 5007 milis

 

Sync + Blocking 모델 보다는 중간중간에 다른 작업을 할 수 있지만 같은 시간을 공유하기 때문에 최종적으로 끝나는 시간은 달라지지 않는다. 게다가 단순 데이터를 기다리는 작업을 위한 것이라면 Sync + Blocking 모델 보다 더 많은 함수 호출을 하기 때문에 오버헤드가 크다.

Asynchronous + Blocking

이제 비 동기 방식으로 처리하는 Async + Blocking 모델에 대해 알아보자. 보통 IO Multiplexing 으로 더 많이 부르고, select, epoll 으로 많이 사용한다. 기본적으로 Blocking IO 지만 마치 Non-Blocking IO 처럼 동작한다.

 

아래 예제를 통해서 알아보자. 여러 개 work 함수에 결과를 리턴 받을 channel(file descriptior. socket, or queue 라고 생각하면 된다)을 인자로 주고 goroutine(light weigh thread 라고 생각하면 된다)으로 동시에 실행한다. 이 후 각 channel로부터 응답을 받으면 각 callback 함수를 호출하고, 모든 work 함수로부터 응답을 받으면 종료되게 했다.

package main

import (
	"log"
	"reflect"
	"time"
)

func work(i int, ch chan bool) {
	time.Sleep(1 * time.Second)
	log.Printf("Work done %d\n", i)
	ch <- true
}

func callback(i int) {
	log.Printf("callBack %d", i)
}

func main() {
	start := time.Now()
	log.SetFlags(log.LstdFlags | log.Lmicroseconds)
	log.Printf("start")

	var chs [5]chan bool

	for i := 0; i < 5; i++ {
		chs[i] = make(chan bool)
		go work(i, chs[i]) // start work
		log.Printf("Work start %d\n", i)
	}
	received := make([]bool, 5)
	for {
		allRecived := true
		for i := 0; i < 5; i++ {
			if !received[i] {
				allRecived = false
				break
			}
		}

		if allRecived {
			break
		}

		cases := make([]reflect.SelectCase, len(chs))
		for i, ch := range chs {
			cases[i] = reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(ch)}
		}
		chosen, _, _ := reflect.Select(cases)
		callback(chosen)
		received[chosen] = true
	}

	log.Printf("take %d milis\n", time.Since(start).Milliseconds())
}

 

위 코드를 실행하면 아래와 같은 결과를 볼 수 있다. 위의 Sync + X 모델에 비해 비약적인 성능 향상을 볼 수 있다. 이전 work 함수의 종료를 기다리지 않아도 되니 모든 작업이 한 번에 끝나게 된다.

$ go run hello.go 
2022/07/05 14:44:47.831721 start
2022/07/05 14:44:47.831795 Work start 0
2022/07/05 14:44:47.831803 Work start 1
2022/07/05 14:44:47.831806 Work start 2
2022/07/05 14:44:47.831818 Work start 3
2022/07/05 14:44:47.831823 Work start 4
2022/07/05 14:44:48.832053 Work done 1
2022/07/05 14:44:48.832079 callBack 1
2022/07/05 14:44:48.832083 Work done 0
2022/07/05 14:44:48.832113 Work done 2
2022/07/05 14:44:48.832136 Work done 4
2022/07/05 14:44:48.832144 Work done 3
2022/07/05 14:44:48.832141 callBack 0
2022/07/05 14:44:48.832164 callBack 3
2022/07/05 14:44:48.832168 callBack 4
2022/07/05 14:44:48.832171 callBack 2
2022/07/05 14:44:48.832173 take 1001 milis

 

하지만 이 방식은 callback 함수를 동시에 실행할 수 없고, 한 번에 하나씩만 실행이 가능하다. 위의 결과처럼 Work done 메시지는 거의 동시에 떴는데 callBack 메시지는 하나씩 실행되는 것을 볼 수 있다. 지금은 마치 동시에 실행되는 것처럼 보여서 이 정도도 충분해 보인다. 그럼 아래처럼 callBack이 오래 걸리게 코드를 수정해보자

func callback(i int) {
	time.Sleep(1 * time.Second)
	log.Printf("callBack %d", i)
}

 

자 다시 코드를 실행해보자

$ go run hello.go 
2022/07/05 14:47:38.076553 start
2022/07/05 14:47:38.076635 Work start 0
2022/07/05 14:47:38.076646 Work start 1
2022/07/05 14:47:38.076651 Work start 2
2022/07/05 14:47:38.076667 Work start 3
2022/07/05 14:47:38.076686 Work start 4
2022/07/05 14:47:39.076936 Work done 1
2022/07/05 14:47:39.077021 Work done 3
2022/07/05 14:47:39.077042 Work done 0
2022/07/05 14:47:39.077052 Work done 4
2022/07/05 14:47:39.077064 Work done 2
2022/07/05 14:47:40.077167 callBack 1
2022/07/05 14:47:41.077351 callBack 0
2022/07/05 14:47:42.077539 callBack 3
2022/07/05 14:47:43.077712 callBack 4
2022/07/05 14:47:44.077882 callBack 2
2022/07/05 14:47:44.077904 take 6001 milis

Work done 은 한참 전에 출력됐는데 callBack 은 하나씩 순서대로 출력하는 것을 볼 수 있다.

Aysnchronous + Non-Blocking

마지막으로 Async + Non-Blocking 모델에 대해서 알아보자. AIO 경우 프로세싱과 IO 동시에 처리된다. 요청 즉시 리턴을 받고 main 로직은 다른 작업을 수행할 수 있다. 또한 IO 가 완료되면 IO 로직은 main 로직과 관련 없이 signal or callback 등을 이용해서 IO 이벤트를 처리할 수 있다.

예를 들어 하나의 프로세스의 여러 IO 요청이 생길 때 하나의 IO 가 느려지게 되면 그 시간동안 다른 작업을 할 수 있다.

 

아래 코드를 보자

package main

import (
	"log"
	"sync"
	"time"
)

var wg sync.WaitGroup

func work(i int, f func(int)) {
	time.Sleep(1 * time.Second)
	log.Printf("Work done %d\n", i)
	f(i)
	wg.Done()
}

func callback(i int) {
	log.Printf("callBack start %d", i)
	time.Sleep(1 * time.Second)
	log.Printf("callBack end %d", i)
}

func main() {
	start := time.Now()
	log.SetFlags(log.LstdFlags | log.Lmicroseconds)
	log.Printf("start")

	wg = sync.WaitGroup{}
	wg.Add(5)
	for i := 0; i < 5; i++ {
		go work(i, callback) // start work
		log.Printf("Work start %d\n", i)
	}

	wg.Wait()

	log.Printf("take %d milis\n", time.Since(start).Milliseconds())
}

 

위 코드를 실행하면 아래와 같은 결과를 볼 수 있다. Work Done 이 출력된 즉시 callback 을 수행하고 종료된다.

$ go run hello.go 
2022/07/05 15:04:30.029034 start
2022/07/05 15:04:30.029119 Work start 0
2022/07/05 15:04:30.029126 Work start 1
2022/07/05 15:04:30.029134 Work start 2
2022/07/05 15:04:30.029147 Work start 3
2022/07/05 15:04:30.029155 Work start 4
2022/07/05 15:04:31.029245 Work done 3
2022/07/05 15:04:31.029297 callBack start 3
2022/07/05 15:04:31.029312 Work done 0
2022/07/05 15:04:31.029361 callBack start 0
2022/07/05 15:04:31.029370 Work done 4
2022/07/05 15:04:31.029385 callBack start 4
2022/07/05 15:04:31.029394 Work done 1
2022/07/05 15:04:31.029407 callBack start 1
2022/07/05 15:04:31.029412 Work done 2
2022/07/05 15:04:31.029423 callBack start 2
2022/07/05 15:04:32.029491 callBack end 2
2022/07/05 15:04:32.029519 callBack end 0
2022/07/05 15:04:32.029525 callBack end 3
2022/07/05 15:04:32.029530 callBack end 4
2022/07/05 15:04:32.029535 callBack end 1
2022/07/05 15:04:32.029546 take 2000 milis

 

정리

이 번 포스팅에서 동기/비동기, 블록/논블록 방식에 대해서 알아보고, 4가지의 IO 모델에 대해서 알아봤다. 각 IO 모델은 다른 특징을 가진다. 일반적으로는 Sync + Blocking 로직으로 작성해도 대부분의 코드는 문제가 없으며, 오히려 AIO 를 적용해서 복잡해지고, 버그가 발생할 수도 있다. 상황에 맞게 IO 모델을 적용하자.

이 번 포스팅에 IO Multiplexing, Closure, Goroutine 등 다양한 기법에 대해 나왔는데 이 부분에 대해서도 추후에 다뤄보겠다.

 

참고

https://developer.ibm.com/articles/l-async/