소프트웨어 사색

블럭,논블럭,동기,비동기 이야기

[하마] 이승현 (wowlsh93@gmail.com) 2018. 5. 11. 12:52

 

 

블럭,논블럭,동기,비동기 이야기  

 

블록,논블럭,동기,비동기를 구분하는 것에 대한 글들이 많이 있는데, 어렵게 풀어내는 거 같아서 나름 간단하고 분명하게 구분해 보는 글을 작성 해 본다. 근데 함정이 있는데 분명하게 정답을 말해 준다는게  아니다. 분명하게 정답이 없으며, 불분명하다고 말해주려는 것이다. ㅎㅎ  면접시나 시험지에 적을 정확한 정답을 몰라서 혹시 불안해 하시는 분이 있다면 이 글을 읽고 안심하셔도 될 것이다.(뭐 시험관이 잘못알고 있는것 까지 책임지진 못하겠다. 과감히 논쟁하시라~~ㅎ) 구체적으로 블럭/논블럭에 대한 구분은 비교적 명확하다. 동기/비동기로 넘어가면 말하는 상황에 따라서 조금 달라지기 시작한다. 이제 조합하기 시작하면 문제가 발생하기 시작한다. 이 글에서는 이것의 구분에 대한 설명을 해드릴 것이지만, 구분을 굳이 왜 해야하는가? 라는 의문이 생길 수도 있을 것이다. 그렇다면 내 글의 의도가 먹힌 것이다. 사실 이것은 구분을 명확히 해야하는 문제라기 보단, 이런식으로 시스템이 작동 하기도 하는 구나 라는 "감" 을 잡으면 되는 문제이다. 

여기서 내가 내리는 정의 또한 "정답" 이 아니다. 애초에 정답이 없는 문제라고 생각하고 유연하게 바라보자. 즉 1+1=2 같은 종류의 문제라거나, 자바에서 class 의 정의는? 같은 문제와는 다르다. 디자인패턴 같은 느낌이다. "의도"는 있지만 "구현"은 제각각인.. 

처음에는 각각의 정의를 내려보고, 그 담엔 우체국을 예로 들어서 이야기 식으로 구분을 해 보며, 마지막으로는 코드를 통한 예를 통해 그 "감"을 잡아내는 수확을 얻도록 하자.

 

@ 다 읽기 귀찮고 감 만잡으려면 3번 우체국이야기는 꼭 읽자

@ 애초에는 I/O 과 연관되어서 정의내리는 것이었는데, I/O 상관없는 비동기가 자주 사용되면서 더 희미해졌다.

 

 

1. 개별 정의


블럭/논블럭 

- 블럭/논블럭는 함수호출에서의 이야기이다.(기술적으로 명확히 구분된다.) 
- A 라는 함수를 호출했을때, A라는 함수를 호출 했을 때 기대하는 행위를 모두 끝마칠때까지 기다렸다가 리턴되면, 이것은 블로킹 되었다고 한다.
- A 라는 함수를 호출 했는데, A라는 함수를 호출 했을 때 기대하는 어떤 행위를  요청 하고 "바로" 리턴되면 이것은 논블럭킹 되었다고 한다.

동기/비동기

- 동기/비동기는 행위에 대한 이야기이다.
- 여기서 "행위"는 단순히 서로 다른 쓰레드 or 프로세스 or  서버에서 일어나는 일련의 동작들 이라고 치환해서
  생각하면 이해하기는 쉽다. 
- 동시성(concurrent) 의 문제이지 병행성(parallel ) 과는 무관하다. (병행성에 대한 의식은 잠시 잊어버리자) 

- A 라는 행위와 B 라는 별개의 행위가 있다고 하자. A 라는 행위와 B 라는 행위가 동시(or 순차적이지 않다면)에 실행되고 있으면 비동기라고 한다. 여기서 제약이 하나 있는데 A,B 행위 사이에는 인과관계가 있어야 한다. 즉 웹서버를 예로 들어서 멀티쓰레드로 각각 A와B가 다른 클라이언트와 작업 할 때 둘은 동시에 작업하고 있지만, 둘의 인과관계는 없지 않나? 이땐 비동기라고 볼 수 없다. A라는 행위의 결과를 B라는 행위에서 언젠간 이용하게 될 때 비동기라고 본다. 

- A라는 행위와 B라는 행위가 순차적으로 작동한다면 동기라고 한다. 
- 동기적 행동에는 하나가 더 있다. A라는 행위가 별개의 것이 아니라, B라는 행위를 관찰하는 행위라면 이것이 동시에 일어나더라도 동기이다. 기술적으로 말해서 A라는 쓰레드와 B라는 쓰레드가 따로 돌아 간다고 해도, 어떤 하나의 행위가 다른 행위에 밀착되어 있다면 두 행위가 다른 쓰레드에서 벌어지더라도 동기란 말이다. 관찰하는 행위라는 말자체가 정확한 기술적 구분이 되는게 아니기 때문에 추상적이라는 표현을 사용한 것이며, 이 글의 가장 불분명한 요소 중 하나이니 잘 기억해 두도록하자. 이해가 안가면 다음의 우체국 예제와 실제 코드예제를 통해 이해할 수 있을 것이다.

사실 여기까지만 읽으셔도 된다. "굳이" 조합까지는 생각 할 필요는 없다. 조합은 내 개인적 상상력의 산물이다. 

2.조합 정의  
 
블럭/논블럭과 동기/비동기를 조합해서 상상을 해보자. 


블럭/동기 

A가 실행되다가 B라는 일을 수행하는 함수를 호출해서 B를 시작한다. B라는 일이 끝나면 함수를 리턴한다. A와 B는 순차적으로 진행되기 때문에 동기이며,  B라는 일을 하는 함수를 호출하고 그 일이 끝나고 나서야 리턴되므로 블럭된 것이다. 따라서 블럭/동기 

 

블럭/비동기 

어떻게 블럭되었는데 A,B라는 일이 동시에 일어나는가? 설명을 들어보고 이런 경우를 말하는구나라는 "감"을 잡아보자.

일단 A는 B라는 일을 시킨다. 그리고 바로 리턴하고 (여기서는 논블럭)  B는 일을 시작하고, A도 자신의 일을 한다. A는 중간에 B라는 일이 하는 중간 결과를 보고 받아서 처리해야한다. A는 B에게 요청을 해서 중간결과를 기다린다(블록), 요청의 결과를 받고 나서 그 결과를 이용해서 A는 자신의 일을 처리한다. 동시에 B 는 또 자신의 일을 동시에 한다. (비동기) A는 다시 B에게 중간결과를 요청해서 기다린다 (블록) , 요청의 결과를 받고 A는 자신의 일을 , B는 자신의 일을 한다. 반복된다.

이 글을 읽고, 사실 갸우뚱 해야한다. 중간에 블록되는 동안에는 "동기" 라고 말 할 수 있기 때문이다. 즉 어느 한 순간에 대해 해석하자면 틀릴 수도 있는것이다. 즉 처음부터 말해왔듯이 "정답"이 존재하지 않는다. 다만 이런 패턴들이 분명히 사용되고 있구나라고 감을 잡는게 목적이다.

 

논블럭/동기 

이것이 예도 위의 블럭/비동기와 비슷한데 조금 다른 늬앙스에 대해서 "감"을 잡아보자.

이것도 역시 A는 B라는 일을 시킨다. 바로 리턴한다. (논블럭)  B는 일을 시작하는데, A는 자신의 일을 하지 않는다. A의 하는 일이란 그저 B가 하는일을 확인하는 것이다. B가 결과 보고(중간 보고가 아니다) 를 했는지를 확인하는 함수를 호출하고 ,바로 리턴한다 (논블럭) 즉 결과 보고를 받을 때 까지 기다리는게 아니라, 결과 보고가 나왔는지 확인하고 바로 리턴하는 것이다.  이 짓을 계속한다. 즉 함수를 계속 논블럭으로 호출되긴 하나, A는 그저 B를 염탐할 뿐이다. 이 상태를 말한다. 그냥 염탐하지 말고 B가 일을 모두 끝마치고 리턴되길 기다리지 ;;; (그냥 블럭/동기로 하는게 나은 상황이 연출된다) 
 
이후에 B가 결과보고를 하면,B는 자신의 일이 끝난 것이고 A는 이제서야 자신의 일을 처리하게 된다. 즉 순차적이라는 말이다. 따라서 동기~
 

논블럭/비동기 

간단하다. A는 B의 일을 시작시키고 바로 리턴한다 (논블럭) 그리고 A와B는 각자 자신의 일을 한다 (비동기) 

 

 

2. 실행활에서 일어나는 우체국 이야기로 풀어보자.

 

블럭/동기 

우체국에 배달 트럭들이 줄을 서 있다. 우체국에 들어오는 물품들을 싣기 위해서인데,
 
- 1번 트럭이 우체국에 내 것들을 가져와주세요 요청하고 기다린다. (블럭)

- 우체국은 1번 트럭에게 주기 위한 물건들을 찾아서 싣기 시작한다. 

- 2번트럭은 1번트럭에 물건이 다 싣기를 기다린다. (블럭)

- 3번 트럭도 기다린다. (블럭)

- 1번트럭이 물건을 싣고 떠나면, 우체국은 이제 2번 트럭의 물건을 찾아서 싣는다. (동기) 

모든 일들이 순차적으로 일어 난다 (동기) 

 

블럭/비동기 

우체국에 가서 내가 필요한 물품은 무엇이라고 접수원에게 말을 하고 집으로 돌아온다.

- 우체국은 물품을 준비하고, 나는 집에서 집안 청소를 한다. (비동기)

- 우체국에 전화 해서 접수원과 통화한다. 물품이 준비되었냐고 물어본다. 접수원은 준비될 때 까지 기다리라고 한다. 나는 하염없이 기다린다 (블럭)

- 접수원이 준비됬다고 말한다. 나는 트럭을 가지고 우체국으로 가서 물건을 싣고 온다.

- 우체국은 자신의 일을 하고, 나는 싣고 온 물건을 배달한다 (비동기) 

중간에 블럭되는 지점이 있지만, 그 이전과 이후에는 각자 자신의 일을 한다. 

 

논블럭/동기 

우체국에 가서 내가 필요한 물품은 무엇이라고 접수원에게 말을 하고 집으로 돌아온다.

- 우체국은 물품을 준비하고, 나는 전화기를 붙잡는다.

- 우체국에 전화 해서 접수원과 통화한다. 물품이 준비되었냐고 물어본다. 접수원은 안됬다고 말한다. 나는 전화를 바로 끊는다. (논블럭)  

- 전화를 끊고, 집안 청소를 하는게 아니라, 다시 우체국에 전화한다. 안됬다고 하면 바로 끊는다 (논블럭)

- 계속 반복적으로 전화한다 (논블럭이며, 나는 내 일을 하는게 아니라 우체국의 일에 매달리고 있으므로 동기) 

- 이번 전화에는 접수원이 준비됬다고 말한다. 나는 트럭을 가지고 우체국으로 가서 물건을 싣고 온다.

- 나는 싣고 온 물건을 배달한다.

중간 중간 논블럭으로 전화를 바로 끊지만, 끊고 나서 바로 또 전화를 하므로 동기

 

* 이 경우에 내가 배달하는 동안에는 현실과 좀 다르지만 우체국은 쉰다고 생각 해야한다.  (동기

 

논블럭/비동기 

우체국에 가서 내가 필요한 물품은 무엇이라고 접수원에게 말을 하고 트럭을 놓고 집에 온다. (논블럭)
트럭(버퍼) 크기가 크다면 우체국에서 많이 채워 줄 것이다. (하지만 좀 더 시간이 걸리겠지) 

- 우체국은 물품을 준비하고, 나는 집에 와서 내일 을 한다 (비동기)

- 전화 따위는 하지 않는다. 우체국에서 알아서 트럭에 짐을 채워서 나에게 트럭이 준비됬으면 연락 할 것이기 때문이다. 

- 트럭이 가득 찼다고 연락이 왔다. 나는 트럭을 가지고서 배달을 시작하고 우체국은 자신의 일을 한다.

이것이 논블럭/비동기이다. 완전 효율적이지 않는가? 

하지만 이것도 병목지점이 있다. 어디일까? 
그렇다. 이 배달기사는 트럭이 한대 뿐이다. 
트럭이 한대 뿐이기 때문에, 배달하는 동안에는 우체국에서 또 다른 짐을 싣지 못한다.
 
어떻게 해결 할까? 간단하다. 트럭을 2개 만드는 것이다 (기술적으로 버퍼를 2개)
그러면 한대는 배달하는 동안에 우체국에 다른 한대를 맡겨 놓는 것이다.
이렇게 되면 배달일 끝날 쯤에는 우체국에 가있는 트럭은 가득 차 있을 것이고, 나는 연속적으로 배달을 할 수 있어서 돈을 많이 벌 수 있을 것이다.
 
여기서 끝이 아니다 병목이 또 하나 있다. 이번엔 무엇인가?
그렇다 배달기사가 하나라는 것이다. 
 
우체국에서 또 다른 트럭이 벌써 가득 차 있다고 연락이 왔지만, 배달중이라 그것을 처리 할 수가 없다. 
이때 어떻게 해야하나? 
그렇다 배달알바를 구하면 된다. 한대의 트럭이 준비되면 그 트럭이 짐을 3등분해서 배달알바 3명에게 나눠준다. 또 다른 트럭이 준비되면 , 배달알바가 끝난 알바생에게 나눠주거나 또 다른 알바생에게 나눠주면 된다.
 
일의 크기에 따라서 알바생을 늘리면 되는 것이다. 이 알바생이 소프트웨어에서 무엇일까?
그렇다~~ 멀티쓰레드이다.
 
비동기 / 싱글쓰레드로 짧게 짧게 일하는 곳 (Node 비동기 서버에서 간단한 리턴만 서비스 하는 곳)에서는 멀티 쓰레드를 굳이 도입하지 않아도 효율적이지만, 백단에서 먼가 해야 할 것이 많다면 (CPU intensive) 이렇게 멀티쓰레드를 추가 해주면 성능이 대폭 올라 갈 것이다.
 

3. 실제 코드 예제로 풀어보자.

 

블럭되어 동기식으로 일처리 - javascript

const fs = require('fs'); 
const data = fs.readFileSync('/file.md');

파일 다 읽을 때 까지 함수가 멈춰져 있으며, (블럭) 다른일도 못한다 (동기)

논블럭되어서 비동기식으로 일처리 - javascript

const fs = require('fs'); 
fs.readFile('/file.md', (err, data) => {  
	// readFile 호출해 놓고 바로 리턴한다.    
	if (err) throw err;  // 하지만 이 일에 대한 인과관계 장치를 마련해 둔다.
 });  ... 다른일을 한다 ...
파일 읽으라고 명령해 두고 바로 리턴(논블럭) 바로 다른 일을 한다 (비동기) 


논블럭인데 비동기는 아니다  - golang
func start_server() {     
	 l, err := net.Listen(CONN_TYPE, CONN_HOST+":"+CONN_PORT)    
	 defer l.Close()     
	 for {        
		 conn, err := l.Accept()         
		if err != nil {            
			log.Print(err)             
			continue         
		}    
	 go handle_client(conn)    
 } 
}
go 루틴으로 (go handle_client) 개별 처리하게 만들고 바로 리턴해서 논블럭이라 할 수 있다. 하지만 go 루틴으로 분기된 것들은 멀티쓰레드로 동시에 일은 하지만 인과관계 부족으로 동기/비동기와 무관하다고 볼 수 있다.

논블럭이면서 비동기  - golang

아래의 예처럼 go 루틴으로 분기시킨 후에 go 채널로써 상호작용(인과관계)를 발생시키는 경우에는 비동기식이라 할 수 있을 것이다.
 sigs := make(chan os.Signal, 1)  
 signal.Notify(sigs)   
go func() {    
    s := <-sigs     
    log.Printf("RECEIVED SIGNAL: %s",s)    
    AppCleanup()    
    os.Exit(1)  
}()
 
 
논블럭/동기  - Scala 
 
val myFuture: Future[String] = Future {      
	val f = Source.fromFile("build.sbt")         
    try 
    	f.getLines.mkString("\n") 
    finally
    	f.close()     
}         
    if myFuture.isCompleted {              ....     }        
    Thread.sleep(100)      
    if myFuture.isCompleted {              ...     }      
    Thread.sleep(100)         
    if myFuture.isCompleted {              ...     }
파일을 읽어 주세요 라고 (논블럭) 으로 일을 시키고나서, 자신의 일은 안하고 100초에 한번씩 계속 눈치보면서 확인함 (동기)
물론 실제 저런식으로 무식하게 코딩을 하진 않는다. 
 
// 실제로는 아래처럼 onComplete 에 콜백을 등록해 준다.   
// 더 콜백들을 편하게 조작하기 위해서, 많은 언어에서 지원하는 async/await 또한 지원한다.  
val file = Future { Source.fromFile(".gitignore-SAMPLE").getLines.mkString("\n") }  
file onComplete { 
	case Success(text) => log(text)     
	case Failure(t) => log(s"Failed due to $t")  
 }  이렇게 사용하면 논블럭/비동기라 할만하다.
 

블럭/비동기  - JAVA (NIO)

Selector selector = Selector.open();    
ServerSocketChannel mySocket = ServerSocketChannel.open(); 
InetSocketAddress myAddr = new InetSocketAddress("localhost", 1111); 
mySocket.bind(myAddr);  
mySocket.configureBlocking(false);  
int ops = mySocket.validOps(); 
SelectionKey selectKy = mySocket.register(selector, ops, null);
 while (true) {      
 	selector.select();     
 	Set<SelectionKey> myKeys = selector.selectedKeys();     
 	Iterator<SelectionKey> myIterator = myKeys.iterator();      
 	while (myIterator.hasNext()) {        
 		SelectionKey myKey = myIterator.next();          
 		if (myKey.isReadable()) {                         
 		SocketChannel myClient = (SocketChannel) myKey.channel();             
 		ByteBuffer myBuffer = ByteBuffer.allocate(256);             
		 myClient.read(myBuffer);            
		 String result = new String(myBuffer.array()).trim();        
	 }         
 	crunchifyIterator.remove();     
 } 
 }

 

파일(or 소켓입력)을 읽어주세요 라고 부탁하고 나서, 자신의 일을 하다가 파일이 읽혀졌는지 selector.select() 를 통해서 무한 대기 즉 우체국에 전화 걸어서 접수원이 기다리라고 해서 대기중 (블럭).
 
selector.select() 가 리턴을 함 (접수원이 짐이 가득 실어 졌다고 말함) 
그러면 이제 우체국에가서 짐을 실음.(사실 짐을 싣는 과정도 블러킹이지만 멀티쓰레드를 이용해서 병목을 줄일 순 있음)  짐을 싣는 도중에 우체국은 다른 사람 짐을 처리하기 위해 자기 할 일 함.(비동기) 

 

논블럭/비동기 - C++ (IOCP)

while (! isStop())   {     
	위의 이야기에서는 한명의 트럭기사가 처리했지만, 4명의 오너배달부들이 트럭을 감시 할 수도 있다.      
	그럼 한명이 병목되더라도 나머지 기사들이 들어온 짐들을 처리 할 수 있을 것이다.     
	if (GetQueuedCompletionStatus(m_hIOCP, &dwTrans, &pKey, (LPOVERLAPPED*)&pOV, 64))    
	{         
		if (pOV)         
		{             
			MySession* sess = pOV->m_sess;                 ....             
			if (pOV == &sess->m_recv1) //트럭1(버퍼1)을 다 실었다고 우체국으로 부터 연락옴             
			{                
				bool error = false;                
				sess->m_recv1.m_size = dwTrans; // 받은 데이터 사이즈                  
				sess->m_recv2.Reset(); //recv1 버퍼를 처리할것이고, recv2 버퍼는 OS한테 넘긴다.               
				//트럭을 가져온다(기술적으로는 os버퍼에서 응용버퍼로 데이터를 이동한다)                   
				if (! ReadFile((HANDLE)sess->m_sock, (LPVOID)sess->m_recv2.m_data.c_str(), (DWORD)sess->m_recv2.m_data.size(), &dwTrans, &sess->m_recv2))                 
				{                     
					if (GetLastError() != ERROR_IO_PENDING)                         
					error = true;                
				}                  
			OnData(*sess, sess->m_recv1);// 트럭에 실린 짐을 처리하기 시작한다.                 
		if (error)                    
			OnClose(*sess);             
		}             
		else if (pOV == &sess->m_recv2)             
		{                 
				//우체국에서 트럭2에 짐을 다 실었을 경우에 처리한다.                                 
				//트럭만 바뀌었을 뿐이지 위와 동일             
		}         
		}     
	} 
}    
bool MyServer::onData(MySession& sess, MyPacket& data)
{     
	std::string cmd, dat;    
	while (recvPacket(sess, cmd, dat))    
	{        
		//우체국으로 부터 받은 트럭으로 부터 배달을 시작함                
		//여기서 멀티쓰레드 사용하면 (알바배달부를 더 고용하면) 더 효율적이게 됨                
		//물론 너무 많이 만들어도 곤란하다.          
	}    
	return false;
}  
// 이 코드는 트럭에 실린 짐을 확인하는 코드이다.  
// 즉 트럭에 실린 짐이 김연아에게 갈 짐이 맞는지 확인하는 것이다.  
// 트럭에 실린 짐이 김연아에게 갈 짐 중에서 50% 밖에 실리지 않았다면  
// 김연아의 짐이 100% 될 때까지 트럭을 다시 우체국에 보내고 처리하지 않는다.  
// 기술적으로는 "syn:: ~~~ ::end" 까지의 패킷이 완성되야 일 처리를 시작한다는 것이다.  

static bool recvPacket(MySession& sess, std::string& cmd, std::string& data)    
{         
	size_t st = sess.m_recvs.find("syn::");        
	if (st == std::string::npos)            
		return false;          // [패킷작업]  패킷의 끝인 ::end 를 찾는 작업 등           
		if (dt)             
			data = sess.m_recvs.substr(sz+1, dt);          
	return true;     
}
 

* 참고로 자바로도 가능하다 (NIO2) 

이제 정답이 없다는데 이해했으리라 보고 굳이 구분을 하자면 나는 이렇게 구분한다.

@ 먼저 블로킹/논블로킹은 함수 호출에 관해서 국한 한다.
A가 B를 호출 했을 때 B가 A가 원하는 모든 일을 다 마치고 리턴하면 블로킹이고 다 마치기 전에 리턴하면 논블로킹이다. 
함수가 작동하는 시간하고는 무관하다. 1+1만 리턴하는 함수면 엄청 빠르게 리턴 할 지라도  원하는 행위를 다 했기 때문에 블로킹이다.

@ 동기/비동기는 각기 다른 쓰레드/프로세스/서버에서 일어나는 행위에 대한 동시성에 관한 이야기이다.
즉 쓰레드 혹은 프로세스가 분리되서 행위가 일어나는 데, A쓰레드가 B쓰레드의 결과를 계속 대기하고 있으면 동기이다. 
A(쓰레드,프로세스, 서버)가 자신의 일을 하다가 B의 결과를 이벤트로 받아서 처리하면 비동기이다. 

이런 구분방법은 거의 대부분의 경우에서 의사소통을 하기에 매우 분명하며 적절하다고 생각한다.


위의 글은 아래에도 실려있으며 Q/A 가 추가되어 있으니 참고하십시요. https://okky.kr/article/442803