Skip to content
Home » 멀티 스레드 프로그래밍 | 초보 탈출 #2 – 멀티 스레드 프로그래밍의 1 인기 답변 업데이트

멀티 스레드 프로그래밍 | 초보 탈출 #2 – 멀티 스레드 프로그래밍의 1 인기 답변 업데이트

당신은 주제를 찾고 있습니까 “멀티 스레드 프로그래밍 – 초보 탈출 #2 – 멀티 스레드 프로그래밍의 1“? 다음 카테고리의 웹사이트 https://ro.taphoamini.com 에서 귀하의 모든 질문에 답변해 드립니다: https://ro.taphoamini.com/wiki. 바로 아래에서 답을 찾을 수 있습니다. 작성자 류종택 이(가) 작성한 기사에는 조회수 4,342회 및 좋아요 45개 개의 좋아요가 있습니다.

Table of Contents

멀티 스레드 프로그래밍 주제에 대한 동영상 보기

여기에서 이 주제에 대한 비디오를 시청하십시오. 주의 깊게 살펴보고 읽고 있는 내용에 대한 피드백을 제공하세요!

d여기에서 초보 탈출 #2 – 멀티 스레드 프로그래밍의 1 – 멀티 스레드 프로그래밍 주제에 대한 세부정보를 참조하세요

예제 소스와 자세한 설명은 아래 문서를 참고하시기 바랍니다.
http://10bun.tv/beginner/episode-2

멀티 스레드 프로그래밍 주제에 대한 자세한 내용은 여기를 참조하세요.

멀티 쓰레드 프로그래밍이란? – 반딧불이 코딩

여러 쓰레드가 같은 프로세스 내에서 자원을 공유하면서 작업을 하기 때문에 발생할 수 있는 동기화(Synchronization), 교착상태(deadlock) 와 같은 문제 …

+ 여기에 더 보기

Source: eatnows.tistory.com

Date Published: 5/11/2022

View: 2540

[1. 멀티스레드 프로그래밍 소개] 02. 프로세스와 스레드

멀티스레드 프로그래밍 소개] 02. … 스레드는 다른 스레드를 만들 수 있다; 스레드 생성은 프로그래머가 지시한다; 모든 스레드는 자신 고유의 스택 …

+ 더 읽기

Source: popcorntree.tistory.com

Date Published: 11/17/2021

View: 5167

초보 탈출 #2 – 멀티 스레드 프로그래밍 1 – 10분 TV

멀티 스레드가 필요한 가장 큰 이유는 프로그램의 실행 효율을 높이기 위해서입니다. 그러나, 스레드를 이용하면 코드가 복잡해지고 디버깅하기가 까다로워 집니다.

+ 여기에 더 보기

Source: 10bun.tv

Date Published: 2/5/2021

View: 8225

10주차 과제 : 멀티쓰레드 프로그래밍 – velog

멀티쓰레드 프로그래밍 자바에서 제공하는 멀티쓰레드 프로그래밍에 대해 공부해보자 Thread 클래스와 Runnable 인터페이스 쓰레드의 상태 쓰레드 …

+ 더 읽기

Source: velog.io

Date Published: 10/8/2021

View: 9989

[C++] 멀티스레딩 프로그래밍 (1)

멀티스레딩 프로그래밍(multithreaded programming)은 프로세서 유닛이 여러 개 장착된 컴퓨터 시스템에서 중요한 기법이며, 이를 이용하여 시스템에 …

+ 여기에 자세히 보기

Source: junstar92.tistory.com

Date Published: 2/13/2022

View: 816

멀티 스레드(multi thread) – 코딩의 시작, TCP School

자바에서는 프로그래머가 메모리에 직접 접근하지 못하게 하는 대신에 가비지 컬렉터가 자동으로 메모리를 관리해 줍니다. 이러한 가비지 컬렉터를 이용하면 프로그래밍을 …

+ 여기를 클릭

Source: www.tcpschool.com

Date Published: 9/29/2021

View: 6621

멀티 쓰레드 프로그램 설계를 위한 8가지 규칙 – 브런치

이 책의 제목에 바로 포함되어 있기 때문에 다음 문장 은 놀라운 일이 아닙니다. 동시 프로그래밍은 여전히 과학보다 예술 입니다.

+ 여기에 더 보기

Source: brunch.co.kr

Date Published: 11/4/2021

View: 460

멀티 스레드 기반 소켓 프로그래밍 – JOINC EDU

멀티 스레드 기반 소켓 프로그래밍스레드에 대한 자세한 내용은 에 자세히 기술 되어 있다. 여기에서는 소켓 프로그래밍을 중심으로 멀티 스레드 기술이 가지는 특징을 …

+ 여기에 보기

Source: www.joinc.co.kr

Date Published: 10/8/2021

View: 7963

멀티스레드 프로그래밍 – IBM

멀티스레드 프로그래밍. 이 절에서는 스레드 라이브러리(libpthreads.a)를 사용하여 멀티스레드 프로그램을 작성하기 위한 지침을 제공합니다. AIX® 스레드 라이브러리 …

+ 여기에 표시

Source: www.ibm.com

Date Published: 6/10/2021

View: 3863

[운영체제(OS)] 4. 멀티쓰레드(Multithreaded Programming)

[운영체제(OS)] 4. 멀티쓰레드(Multithreaded Programming) · 1. Thread · 2. Multithreading · 3. User-level Thread vs Kernel-level Thread · 4. Threading …

+ 여기에 표시

Source: rebro.kr

Date Published: 9/18/2022

View: 2611

주제와 관련된 이미지 멀티 스레드 프로그래밍

주제와 관련된 더 많은 사진을 참조하십시오 초보 탈출 #2 – 멀티 스레드 프로그래밍의 1. 댓글에서 더 많은 관련 이미지를 보거나 필요한 경우 더 많은 관련 기사를 볼 수 있습니다.

초보 탈출 #2 - 멀티 스레드 프로그래밍의 1
초보 탈출 #2 – 멀티 스레드 프로그래밍의 1

주제에 대한 기사 평가 멀티 스레드 프로그래밍

  • Author: 류종택
  • Views: 조회수 4,342회
  • Likes: 좋아요 45개
  • Date Published: 2019. 11. 30.
  • Video Url link: https://www.youtube.com/watch?v=guPB2hXtaQA

멀티 쓰레드 프로그래밍이란?

반응형

멀티쓰레드 프로그래밍

Process란?

실행중인 프로그램을 의미

운영체제로부터 메모리 공간을 할당 받아 실행중인 것을 말한다. 이러한 프로세스는 프로그램에 사용되는 데이터와 메모리 등의 자원, 쓰레드로 구성된다.

Thread란?

프로세스 내에서 작업을 수행하는 일꾼(주체)

모든 프로세스에는 1개 이상의 쓰레드가 존재하여 작업을 수행

1개의 쓰레드를 가지는 프로세스를 싱글 쓰레드 프로세스라고 한다

2개 이상의 쓰레드를 가지는 프로세스를 멀티 쓰레드 프로세스 라고 한다.

라고 한다. 경량 프로세스라고 불리며 가장 작은 실행단위이다.

프로세스의 자원을 이용해서 작업을 수행한다.

멀티 태스킹(multi-tasking)

여러개의 프로세스가 동시에 실행될 수 있는 것.

멀티 쓰레딩(multi-threading)

하나의 프로세스 내에서 여러 쓰레드가 동시에 작업을 수행하는 것

CPU의 코어(Core)가 한 번에 하나의 작업만 수행할 수 있으므로, 실제로 동시에 처리되는 작업의 개수와 일치한다.

코어가 아주 짧은 시간 동안 여러 작업을 번갈아 가며 수행함으로써 여러 작업들이 모두 동시에 수행되는 것처럼 보이게한다.

프로세스의 성능은 쓰레드의 개수와 비례하지 않는다.

여러 쓰레드가 같은 프로세스 내에서 자원을 공유하면서 작업을 하기 때문에 발생할 수 있는 동기화(Synchronization), 교착상태(deadlock) 와 같은 문제들을 고려해서 신충히 프로그래밍 해야한다.

자바 Thread

JVM을 사용하면 여러 쓰레드를 가질 수 있다. 모든 쓰레드에는 우선순위가 있다. 우선 순위가 높은 쓰레드가 우선 순위가 낮은 쓰레드보다 우선적으로 실행된다. 각 쓰레드는 데몬 쓰레드로 마크가 될수도 있다. 데몬 쓰레드란 사용자 쓰레드를 보조하는 쓰레드이며, 자바에서는 대표적으로 Garbage Collector가 데몬 쓰레드이다.

일부 쓰레드에서 새로운 Thread 객체를 생성할 때 새로운 쓰레드는 자신을 생성한 쓰레드의 우선 순위와 동일한 우선순위를 가지며 데몬 쓰레드일 경우 데몬 쓰레드가 된다.

JVM이 시작될때 일반적으로 하나의 쓰레드가 있는데 다음 중 하나가 발생할 때 까지 쓰레드를 유지한다.

Runtime 클래스의 종료 메서드가 호출되었으며 보안관리자(Security manager)가 종료 조작이 발생하도록 허용했을 때 종료된다.

데몬 쓰레드가 아닌 모든 쓰레드는 실행된 후 run() 메서드의 작업이 끝나거나 run 메서드 이외에서 예외를 throw 했을 때 종료된다.

모든 쓰레드는 식별을 목적으로 이름을 가지고 있다. main메서드의 작업을 수행하는 것도 하나의 쓰레드로 이름이 main이다. 둘 이상의 쓰레드가 동일한 이름을 가질수 있고 쓰레드가 생성될 때 이름이 지정되지 않으면 Thread-숫자 형식으로 새 이름이 생성된다. 숫자는 0부터 시작하여 1씩 증가한다. 따로 명시되지 않는 한 쓰레드 생성자 또는 메서드에 null 값을 넣으면 NullPointerException 이 throw된다. Thread 클래스의 생성자들 중 init()를 살표보면 매개변수 중 name 값이 null 이면 NullPointerException 을 던지는 것을 확인할 수 있다.

Thread 클래스와 Runnable 인터페이스

자바에서 쓰레드를 생성하는 방법은 크게 두가지로 나눌 수 있다

Thread 클래스를 사용 Runnalbe 인터페이스를 사용

1. Thread 클래스를 상속받는 방법

클래스를 Thread의 자식 클래스로 선언하는 것이다. 자식 클래스는 실행 메서드(run 메서드)를 재정의 하여 인스턴스를 할당하고 실행할 수 있다. Thread 클래스는 Runnable 인터페이스를 구현한 클래스이다.

public class ThreadTest { public static void main(String[] args) { SubThread thread = new SubThread(); thread.start(); } } class SubThread extends Thread { @Override public void run() { for (int i = 0; i < 5; i++) { System.out.println("Thread 클래스를 상속받은 클래스."); } } } > Task: ThreadTest.main() > Thread 클래스를 상속받은 클래스. > Thread 클래스를 상속받은 클래스. > Thread 클래스를 상속받은 클래스. > Thread 클래스를 상속받은 클래스. > Thread 클래스를 상속받은 클래스.

2. Runnalbe 인터페이스를 구현하는 방법

Runnalbe 인터페이스를 구현하는 클래스를 만들어 사용하는 방법으로 해당 클래스는 run() 메서드를 구현한다. run() 메서드를 구현했다면 클래스의 인스턴스를 할당하고 Thread를 만들 때 인수로 전달하고 시작할 수 있다.

public class ThreadTest { public static void main(String[] args) { Runnable myThread = new MyThread2(); Thread thread = new Thread(myThread); thread.start(); } } class MyThread implements Runnable { @Override public void run() { for (int i = 0; i < 5; i++) { System.out.println("Runnable을 구현해서 만든 쓰레드"); } } } > Task: ThreadThest.main() > “Runnable을 구현해서 만든 쓰레드” > “Runnable을 구현해서 만든 쓰레드” > “Runnable을 구현해서 만든 쓰레드” > “Runnable을 구현해서 만든 쓰레드” > “Runnable을 구현해서 만든 쓰레드”

Runnable 인터페이스를 구현한 경우, 클래스의 인스턴스를 생성한 다음 인스턴스를 Thread 클래스의 생성자의 매개변수로 제공해야 한다.

public vlass Thread { private Runnable r; // Runnable을 구현한 클래스의 인스턴스를 참조하기 위한 변수 public Thread(Runnable r) { this.r = r; } public void run() { if (r != null) { r.run(); // Runnable 인터페이스를 구현한 인스턴스의 run()을 호출 } } }

Thread 클래스를 상속받으면 다른 클래스를 상속 받을 수 없기 때문에, Runnable 인터페이스를 구현하는 방법이 일반적이다. Thread 클래스가 다른 클래스를 확장할 필요가 있을 경우에는 Runnable 인터페이스를 구현하면 된다.

Runnable 인터페이스를 구현하는 방법은 재사용성(reusablity)이 높고 코드의 일관성(consistency)을 유지할 수 있기 때문에 보다 객체지향적인 방법이라 할 수 있다.

Runnable 인터페이스

Runnable 인터페이스는 함수형 인터페이스로 run() 추상 메서드 하나만 존재한다. 구현하는 클래스에서 run() 메서드를 구현하는 걸로 쓰레드에게 작업할 내용을 설정할 수 있다.

Thread 클래스

Thread 클래스에는 접근지정자가 public 인 필드는 3개만 존재한다. 모두 쓰레드의 우선 순위에 대한 상수 필드이다.

public final static int MIN_PRIORITY = 1 (우선 순위 최소값)

public final static int NORM_PRIORITY = 5 (우선 순위 기본값)

public final static int MAX_PRIORITY (우선 순위 최대값)

Thread 클래스의 생성자에서 인자들이 가지는 의미

String gname

이름을 지정하지 않고 쓰레드를 생성할 때 자동으로 생성되는 이름이다. 자동으로 생성되는 이름은 Thread-정수 형식을 가진다. String name

쓰레드 생성자에 인자로 주는 새로운 쓰레드의 이름을 의미한다.

이름을 지정하지 않고 쓰레드를 생성할 때 자동으로 생성되는 이름이다. 자동으로 생성되는 이름은 형식을 가진다. Runnable target

target은 쓰레드가 시작될 때 run() 메서드가 호출될 객체이다.

target은 쓰레드가 시작될 때 메서드가 호출될 객체이다. ThreadGroup group

group은 생성할 쓰레드를 설정할 쓰레드 그룹이다. group 값이 null 이면서 security manager가 존재한다면 그룹은 SecurityManager.getThreadGroup()에 의해서 결정된다. security manager가 없거나 SecurityManager.getThreadGroup()이 null을 반환한다면 현재 쓰레드의 그룹으로 설정된다.

group은 생성할 쓰레드를 설정할 쓰레드 그룹이다. group 값이 null 이면서 security manager가 존재한다면 그룹은 SecurityManager.getThreadGroup()에 의해서 결정된다. security manager가 없거나 SecurityManager.getThreadGroup()이 null을 반환한다면 현재 쓰레드의 그룹으로 설정된다. long stackSzie

새로운 쓰레드의 스택 사이즈를 의미한다. 0이면 이 인자는 없는것과 같다. stackSize는 가상 머신이 쓰레드의 스택에 할당 할 주소 공간의 대략적인 바이트 수를 말한다.

구현과 실행에 관련된 run() 메서드와 start() 메서드

public void run() : 쓰레드가 실행되면 run() 메서드를 호출하여 작업을 한다.

public synchronized void start() : 쓰레드를 실행시키는 메서드이다. start 메서드가 호출되었다고 해서 바로 실행되는 것이 아니라 일단 실행 대기 상태에 있다가 자신의 차례가 되어야 실행된다.

run() 메서드와 start() 메서드의 차이점

쓰레드를 시작할때 start() 메서드를 호출해서 쓰레드를 실행한다. main 메서드에서 run() 메서드를 호출하는 것은 생성된 쓰레드를 실행시키는 것이 아니라 단순히 메서드를 호출하는 것이다. 반면 start() 메서드를 호출하면 새로운 쓰레드가 작업을 실행하는데 필요한 새로운 호출 스택(call stack)을 생성한 다음 run() 메서드를 호출한다. 새로 생성된 콜 스택에 run() 메서드가 첫 번째로 올라가데 한다. run() 메서드의 수행이 종료된 쓰레드는 콜 스택이 모두 비워지면서 생성된 호출 스택도 소멸된다.

한 번 실행이 종료된 쓰레드는 다시 실행 할 수 없다. 즉 하나의 쓰레드에 대해 start() 메서드가 한 번만 호출될 수 있다는 뜻이다.하나의 쓰레드 객체에 대해 start() 메서드를 두 번이상 호출하면 실행시 IllegalThreadStateException 이 발생한다.

// 다음과 같은 경우는 첫번째 스레드를 실행 후 또 다른 쓰레드를 생성하여 실행하기 때문에 정상 실행된다. MyThread1 thread1 = new MyThread1(); thread1.start(); thread1 = new MyThread1(); thread1.start();

한 쓰레드에서 예외가 발생하여 종료되더라도 다른 쓰레드의 실행에는 영향을 미치지 않는다.

Thread 메서드

메서드 설명 static void sleep(long millis)

static void sleep(long millis, int nanos) 지정한 시간(1/1000초) 동안 쓰레드를 일시정지 시킨다.

지정한 시간이 지나고 나면 다시 실행 대기 상태가 된다. void join()

void join(long millis)

void join(long millis, int nanos) 지정한 시간동안 쓰레드가 실행되도록 한다.

지정된 시간이 지나거나 작업이 종료되면 join()을 호출한 쓰레드로 다시 돌아와 실행을 계속한다. void interrupt() 쓰레드에게 작업을 멈추라고 요청한다. 쓰레드의 interrupted 상태를 false에서 true로 변경한다. static boolean interrupted() sleep()이나 join()에 의해 일시정지 상태인 쓰레드를 깨워서 실행대기상태로 만든다.

해당 쓰레드에서는 interruptedException이 발생함으로써 일시정지 상태를 벗어나게 된다. @Deprecated void stop() 쓰레드를 즉시 종료시킨다. @Deprecated void suspend() 쓰레드를 일시정지 시킨다. resume()을 호출하면 다시 실행 대기상태가 된다, @Deprecated void resume() suspend()에 의해 일시정지 상태에 있는 쓰레드를 실행대기 상태로 만든다. static void yield() 실행 중에 자신에게 주어진 실행시간을 다른 쓰레드에게 양보(yield)하고 자신은 실행 대기 상태가 된다. currentThread() 현재 실행중인 thread 객체의 참조를 반환한다. destroy() clean up 없이 쓰레드를 파괴한다.@Deprecated 된 메서드로 suspend()와 같이 교착상태(deadlock)을 발생시키기 쉽다. isAlive() 쓰레드가 살아있는지 확인하기 위한 메서드이다. 쓰레드가 시작되고 아직 종료되지 않았다면 살아있는 상태이다. setPriority(int newPriority) 쓰레드의 우선순위를 새로 설정할 수 있는 메서드이다. getPriority() 쓰레드의 우선순위를 반환한다. setName(String name) 쓰레드의 이름을 새로 설정한다. getName(String name) 쓰레드의 이름을 반환한다. getThreadGroup() 쓰레드가 속한 쓰레드 그룹을 반환한다. 종료됐거나 정지된 쓰레드라면 null을 반환한다. activeCount() 현재 쓰레드의 쓰레드 그룹 내의 쓰레드 수를 반환한다. enumerate(Thread[] tarray) 현재 쓰레드의 쓰레드 그룹내에 있는 모든 활성화된 쓰레드들을 인자로 받은 배열에 넣는다. 그리고 활성화된 쓰레드의 숫자를 int 타입의 정수로 반환한다. dumpStack() 현재 쓰레드의 stack trace를 반환한다. setDaemon(boolean on) 이 메서드를 호출한 쓰레드를 데몬 쓰레드 또는 사용자 쓰레드로 설정한다.

JVM은 모든 쓰레드가 데몬 쓰레드만 있다면 죵료된다. 이 메서드는 쓰레드가 시작되기 전에 호출되어야 한다. isDaemon() 이 쓰레드가 데몬 쓰레드인지 아닌지를 확인하는 메서드이다. 데몬 쓰레드면 true, 아니면 false를 반환한다. getStackTrace() 호출하는 쓰레드의 스택 덤프를 나타내는 스택 트레이스 요소의 배열을 반환한다. getAllStackTrace() 활성화된 모든 쓰레드의 스택 트레이스 요소의 배열을 value로 가진 map을 반환한다. key는 thread이다. getId() 쓰레드의 고유값을 반환한다. 고유값은 long 타입의 정수이다. getState() 쓰레드의 상태를 반환한다.

쓰레드의 상태

쓰레드의 현재 상태를 나타낸다.

상태 의미 NEW 쓰레드 객체는 생성되었지만 아직 시작되지 않은 상태 (start()가 호출되지 않은 상태) RUNNABLE 쓰레드가 실행중인 상태 또는 실행 가능한 상태 BLOCKED 쓰레드가 실행 중지 상태이며, 모니터 락(monitor lock)이 풀리기를 기다리는 상태 WAITING 쓰레드가 대기중인 상태, 쓰레드의 작업이 종료되진 않았지만 실행가능하지 않은(unrunnable) 일시 정지 상태 TIMED_WAITING WAITING 상태에서 일시정지시간이 지정된 경우를 의미 TERMINATED 쓰레드의 작업이 종료된 상태

sleep()

밀리세컨드와 나노세컨드의 시간단위로 값을 지정할 수 있지만 어느 정도 오차가 발생할 수 있다.

sleep()에 의해 일시정지 상태가 된 쓰레드는 지정된 시간이 다 되거나 interrupt() 가 호출되면 (InterruptedExcetpion 발생시킴) 깨어나 실행 대기 상태가 된다.

sleep() 메서드를 호출할 때는 항상 try-catch 문으로 InterruptedExcetpion을 예외처리 해주어야 한다.

sleep()는 항상 현재 실행 중인 쓰레드에 대해 작동한다. static으로 선언되어 있으며 참조변수를 이용해서 호출하기 보다는 Thread.sleep(1000)와 같이 호출해야 한다.

interrupt()

public void interrupt()

쓰레드의 interrupted 상태를 false에서 true로 변경, 쓰레드에게 작업을 멈추라고 요청한다. 요청만 할 뿐 쓰레드를 강제로 종료시키는 것은 아니다.

쓰레드의 interrupted 상태를 false에서 true로 변경, 쓰레드에게 작업을 멈추라고 요청한다. 요청만 할 뿐 쓰레드를 강제로 종료시키는 것은 아니다. public boolean insInterrupted()

쓰레드의 interrupted 상태를 반환, interrupt()가 호출되었는지 확인하는데 사용할 수 있지만 interrupted()와 달리 interrupted 상태를 false로 초기화하지 않는다.

쓰레드의 interrupted 상태를 반환, interrupt()가 호출되었는지 확인하는데 사용할 수 있지만 interrupted()와 달리 interrupted 상태를 false로 초기화하지 않는다. public static boolean interrupted()

현재 쓰레드의 interrupted 상태를 반환 후 false로 변경, 쓰레드가 sleep(), wait(), join()에 의해 ‘일시정지 상태 (waiting)’에 있을때 해당 쓰레드에 대해 interrupt()를 호출하면 sleep(), wait(), join()에서 interruptedException 이 발생하고 쓰레드는 ‘실행 대기 상태(Runnalbe);로 바뀐다.

suspend(), resume(), stop()

suspend()

sleep()처럼 쓰레드를 일시정지 한다.

sleep()처럼 쓰레드를 일시정지 한다. resume()

suspend()에 의해 일시정지 상태에 있는 쓰레드를 실행대기 상태로 만든다.

suspend()에 의해 일시정지 상태에 있는 쓰레드를 실행대기 상태로 만든다. stop()

호출되는 즉시 쓰레드가 종료된다.

위 세 메서드는 쓰레드의 실행을 제어하는 가장 손쉬운 방법이지만, suspend()와 stop()가 교착 상태(deadlock)을 일으키기 쉽게 작성되어 있어 이 메서드들은 모두 @Deprecated 되었다.

yield()

쓰레드 자신에게 주어진 실행시간을 다음 차례의 쓰레드에게 양보(yield)한다.

yield()와 interrupt()를 적절히 사용하면, 프로그램의 응답성을 높이고 보다 효율적인 실행이 가능하게 할 수 있다.

join()

쓰레드 자신이 하던 작업을 잠시 멈추고 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 할 때 사용한다.

시간을 지정하지 않으면 해당 쓰레드가 작업을 모두 마칠 때까지 기다린다. 작업중에 다른 쓰레드의 작업이 먼저 수행되어야할 필요가 있을때 join()를 사용한다.

join()도 sleep() 처럼 interrupt()에 의해 대기상태에서 벗어날 수 있으며, join()가 호출되는 부분을 try-catch문으로 감싸서 InterruptedException 을 catch해야 한다.

sleep()와 다른점은 join()는 현재 쓰레드가 아닌 특정 쓰레드에 대해 동작하므로 static 메서드가 아니라는 점이다.

쓰레드의 우선순위

Java에서 각 쓰레드는 우선순위(Priority)에 관한 자신만의 필드를 가지고 있다. 이러한 우선 순위에 따라 특정 쓰레드가 더 많은 시간동안 작업을 할 수 있도록 설정한다. 쓰레드가 수행하는 작업의 중요도에 따라 쓰레드의 우선순위를 다르게 지정하여 특정 쓰레드가 더 많은 작업시간을 갖도록 할 수 있다.

필드 설명 static int MAX_PRIORITY 쓰레드가 가질 수 있는 최대 우선순위를 명시함 static int MIN_PRIORITY 쓰레드가 가질 수 있는 최소 우선순위를 명시함 static int NORM_PRIORITY 쓰레드가 생성될 때 가지는 기본 우선순위를 명시함

getPriority()와 setPriority() 메서드를 통해 쓰레드위 우선순위를 반환하거나 변경할 수 있다. 쓰레드의 우선순위가 가질 수 있는 범위는 1부터 10까지이며, 숫자가 높을 수록 우선순위가 높아진다. 하지만 쓰레드의 우선순위는 상재적인 값일 뿐이다. 우선순위가 10인 쓰레드가 우선순위가 1인 쓰레드보다 10배 더 빨리 수행되는 것이 아니다. 단지 우선순위 10이 1보다 좀 더 많이 실행 큐에 포함되어 좀 더 많은 작업 시간을 할당받을 뿐이다.

Main 쓰레드

Java는 실행 환경인 JVM(Java Virtual Machine)에서 돌아가게 된다.이것이 하나의 프로세스이고 Java를 실행하기 위해 우리가 실행하는 main() 메서드가 메인 쓰레드이다. main()메서드는 메인 쓰레드의 시작점을 선언하는 것이다.

public class MainMethod { public static void main(String[] args) { // … } }

따로 쓰레드를 실행하지 않고 main() 메서드만 실행하는 것을 싱글쓰레드 애플리케이션 이라고 한다.

메인 쓰레드는 자바에서 처음으로 실행되는 쓰레드이자 모든 쓰레드는 메인 쓰레드로 부터 생성된다.

Daemon Thread

데몬 쓰레드는 다른 일반 쓰레드(데몬 쓰레드가 아닌 쓰레드)의 작업을 돕는 보조적인 역할을 수행하는 쓰레드이다. 데몬 쓰레드는 일반 쓰레드의 보조 역할을 수행하므로 일반 쓰레드가 모두 종료되고 나면 데몬 쓰레드의 존재 의미가 없어지기 떄문에 일반 쓰레드가 모두 종료되면 데몬 쓰레드는 강제적으로 자동 종료된다.

isDaemon()

쓰레드가 데몬인지 확인한다. 데몬 쓰레드면 true, 아니면 false

쓰레드가 데몬인지 확인한다. 데몬 쓰레드면 true, 아니면 false setDaemon()

쓰레드를 데몬 쓰레드로 또는 사용자 쓰레드로 변경한다. true면 데몬 쓰레드가 된다.

public class ThreadExample { public static void main(String[] args) { Thread main = Thread.currentThread(); MyThread1 th1 = new MyThread1(); th1.setDaemon(true); System.out.println(“main.isDaemon() : ” + main.isDaemon()); System.out.println(“th1.isDaemon() : ” + th1.isDaemon()); } } class MyThread1 extends Thread { @Override public void run() { super.run(); } } > Task: ThreadExample.main() > main.isDaemon() : false > th1.isDaemon() : true

// long의 최대값 만큼 대기 public class DaemonThread extends Thread { public void run() { try { Thread.sleep(Long.MAX_VALUE); } catch (Exception e) { e.printStackTrace(); } } } public void runCommonThread() { DaemonThread thread = new DaemonThread(); thread.start(); } // 프로그램이 대기하지 않고 그냥 끝나버린다. // 데몬 쓰레드는 해당 쓰레드가 종료되지 않아도 다른 실행중인 일반 쓰레드가 없다면 멈춰버린다. public void runDaemonThread() { DaemonThread thread = new DaemonThread(); thread.setDaemon(true); thread.start(); }

데몬쓰레드를 만든 이유

예를들어 모니터링하는 쓰레드를 별도로 띄워 모니터링을 하다가, Main 쓰레드가 종료되면 관련된 모니터링 쓰레드가 종료되어야 프로세스가 종료될 수 있다. 모니터링 쓰레드를 데몬 쓰레드로 만들지 않으면 프로세스가 종료할 수 없게 된다. 이렇게 부가적인 작업을 수행하는 쓰레드를 선언할 때 데몬 쓰레드를 만든다.

동기화 (Synchronize)

싱글 쓰레드 프로세스의 경우 프로세스 내에서 단 하나의 쓰레드만 작업하기 때문에 프로세스의 자원을 가지고 작업하는데는 별 문제가 없다. 멀티 쓰레드 프로세스의 경우 여러 쓰레드가 같은 프로세스 내의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 주게 된다. 공유 자원 접근 순서에 따라 실행 결과가 달라지는 프로그램의 영역 을 임계구역(critical section) 이라고 한다.

임계구역 해결 조건

상호 배제 (mutual exclusion)

한 쓰레드가 임계구역에 들어가면 다른 쓰레드는 임계구역에 들어갈 수 없다. 이것이 지켜지지 않으면 임계구역을 설정한 의미가 없어진다.

한 쓰레드가 임계구역에 들어가면 다른 쓰레드는 임계구역에 들어갈 수 없다. 이것이 지켜지지 않으면 임계구역을 설정한 의미가 없어진다. 한정 대기 (bounded waiting)

한 쓰레드가 계속 자원을 사용하고 있어 다른 쓰레드가 사용하지 못한 채 계속 기다리면 안된다. 어떤 쓰레드도 무한 대기 (infinite postpone) 하지 않아야 한다. 특정 쓰레드가 임계구역에 진입하지 못하면 안된다.

한 쓰레드가 계속 자원을 사용하고 있어 다른 쓰레드가 사용하지 못한 채 계속 기다리면 안된다. 어떤 쓰레드도 무한 대기 (infinite postpone) 하지 않아야 한다. 특정 쓰레드가 임계구역에 진입하지 못하면 안된다. 진행의 융통성(progress flexibility)

한 쓰레드가 다른 쓰레드의 작업을 방해해서는 안된다.

임계구역과 잠금(lock)의 개념을 활용해서 한 쓰레드가 특정 작업을 마치기 전까지 다른 쓰레드에 의해 방해받지 않도록 할 수 있다.

공유 데이터를 사용하는 코드 영역을 임계구역으로 지정해놓고, 공유 데이터(객체)가 가지고 있는 lock을 획득한 단 하나의 쓰레드만 이 영역 내의 코드를 수행할 수 있게 한다. 해당 쓰레드가 임계 구역내의 모든 코드를 수행하고 벗어나서 lock을 반납해야만 다른 쓰레드가 반납된 lock을 획득하여 임계 구역의 코드를 수행할 수 있게 된다.

이처럼 한 쓰레드가 진행중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 쓰레드의 동기화(synchronization) 이라고 한다.

자바에서 동기화 하는 방법은 3가지로 분류한다.

Synchronized 키워드

Atomic 클래스

Volatile 키워드

Synchronized 키워드

Java 예약어 중 하나로 변수명이나, 클래스명으로 사용이 불가능하다 Synchronized 사용 방법

메소드 자체를 synchronized로 선언하는 방법(synchronized methods) public synchronized void calcSum() { // ,,, }

다른 하나는 메서드 내의 특정 문장만 synchronized로 감싸는 방법 (synchronized statements) synchronized (객체의 참조변수) { // … } 두 가지 방법 모두 lock의 획득과 반납이 자동적으로 이루어지므로 임계구역만 지정해주면 된다.

임계 구역은 멀티쓰레드 프로그램의 성능을 좌우하기 때문에 가능하면 매서드 전체에 락을 거는 것보다 syncjronized 블럭으로 임계구역을 최소화해서 보다 효율적인 프로그램이 되도록 노력해야한다. public class Calculate { private int amount; private int interest; public static Obejct interestLock = new Object(); public Calculate() { amount = 0; } public void addInterest(int value) { synchronized(interestLock) { interest += value; } } public void plus(int value) { synchronized (this) { amount += value; } } public sychronized void minus(int value) { amount -= value; } public int getAmount() { return amount; } }

Atomic

Atomicity(원자성)의 개념은 ‘쪼갤 수 없는 가장 작은 단위’를 뜻한다.

자바의 Atomic Type은 Wrapping 클래스의 일종으로, 참조 타입과 원시 타입 두 종류의 변수에 모두 적용이 가능하다. 사용시 내부적으로 CAS(Compare-And-Swap) 알고리즘을 사용해 lock 없이 동기화 처리를 할 수 있다.

Atomic Type의 경우 volatile과 synchronized와 달리 java.util.concurrent.atomic 패키지에 정의된 클래스이다.

CAS는 특정 메모리 위치와 주어진 위치의 value를 비교하여 다르면 대체하지 않는다.

변수를 선언할때 타입을 Atomic Type으로 선언해주면 된다. ex) AtomicLong

Compare-And-Swap(CAS) 란?

메모리 위치의 내용을 주어진 값과 비교하고 동일한 경우에만 해당 메모리 위치의 내용을 새로 주어진 값으로 수정한다.

현재 주어진 값(현재 쓰레드에서의 데이터)과 실제 데이터와 저장된 데이터를 비교해서 두 개가 일치할때만 값을 업데이트 한다. 이 역할을 하는 메서드가 compareAndSet()이다. 즉 synchronized 처럼 임계구역에 같은 시점에 두개 이상의 쓰레드가 접근하려 하면 쓰레드 자체를 blocking 시키는 매커니즘이 아니다.

Volatile

volatile 키워드는 Java 변수를 Main Memory에 저장하겠다라는 것을 명시하는 것이다.

매번 변수의 값을 Read할 때마다 CPU cache에 저장된 값이 아닌 Main Memory에서 읽는 것이다.

또한 변수의 값을 Write할 때마다 Main Memory에 작성하는 것이다.

volatile 변수를 사용하고 있지 않은 MultiThread 애플리케이션은 작업을 수행하는 동안 성능 향상을 위해서 Main Memory에서 읽은 변수를 CPU Cache에 저장하게 된다. 만약 Multi Thread 환경에서 Thread가 변수 값을 읽어올 때 각각의 CPU Cache에 저장된 값이 다르기 때문에 변수 값 불일치 문제가 발생하게 된다.

Lock과 Condition을 이용한 동기화

오래 기다린 쓰레드가 notify()로 인해 락을 얻는다는 보장은 없다. wait()가 호출되면 실행 중이던 쓰레드는 해당 객체의 대기실 (waiting pool)에서 통지를 기다린다. notify()가 호출되면 해당 객체의 대기실에 있는 모든 쓰레드 중에서 임의의 쓰레드만 통지를 받는다. notifyAll()을 해서 모든 쓰레드에게 통보를 해도 lock을 얻는것은 하나의 쓰레드뿐이기 때문에 다른 쓰레드들은 계속해서 lock을 기다려야 한다. 이처럼 lock을 얻지 못하고 오랫동안 기다리게 되는 현상을 기아현상 (starvation)이라고 한다. 여러 쓰레드가 lock을 얻기 위해 경쟁하는 것은 경쟁 상태 (race confition)이라고 한다.

데드락 (교착상태, DeadLock)

2개 이상의 프로세스가 다른 프로세스의 작업이 끝나기만 기다리며 작업을 더 이상 진행하지 못하는 상태를 교착 상태 (dead lock)이라고 한다.

둘 이상의 쓰레드가 lock을 획득하기 위해 대기하는데 이 lock을 잡고 있는 쓰레드들도 똑같이 다른 lock을 기다리면서 서로 block 상태에 놓이는 것을 말한다. Deadlock은 다수의 쓰레드가 같은 lock을 동시에, 다른 명령에 의해 획득하려 할 때 발생할 수 있다.

교착 상태가 발생하는 원인

교착 상태가 발생하기 위해서는 아래의 4가지 조건을 만족해야 한다. 이 4가지 조건을 교착 상태의 필요조건 이라고 한다.

상호 배제

자원을 공유하지 못하면 교착 상태가 발생한다. 여기서 자원은 배타적인 자원이어야 한다. 배타적인 자원은 임계구역에서 보호되기 때문에 다른 쓰레드가 동시에 사용할 수 없다. 비선점

자원을 빼앗을 수 없으면 자원을 놓을 때까지 기다려야 하므로 교착상태가 발생한다. 점유와 대기

자원 하나를 잡은 상태에서 다른 자원을 기다리면 교착 상태가 발생한다. 원형 대기

자원을 요구하는 방향을 원을 이루면 양보를 하지 않기 때문에 교착상태가 발생한다.

교착 상태 해결방법

교착 상태 예방

교착 상태는 상호 배제, 비선점, 점유와 대기, 원형 대기 라는 4가지 조건을 동시에 충족해야 발생하기 때문에 이 중 하나라도 막는다면 교착 상태는 발생하지 않는다.

교착 상태 회피

자원 할당량을 조절하여 교착 상태를 해결하는 방식이다. 자원을 할당하다가 교착 상태를 유발할 가능성이 있다고 판단하면 자원 할당을 중단하고 지켜보는 것이다.

교착 상태 검출과 회복

교착 상태 검출은 어떤 제약을 가하지 않고 자원 할당 그래프를 모니터링 하면서 교착 상태가 발생하는지 살펴보는 방식이다. 교착 상태가 발생하면 교착 상태 회복 단계가 진행된다.

교착상태를 검출한 후 이를 회복시키는 것은 결론적으로 교착 상태를 해결하는 현실적인 접근 방법이다.

반응형

[1. 멀티스레드 프로그래밍 소개] 02. 프로세스와 스레드

*이 글의 내용은 한국산업기술대학교 게임공학부 정내훈 교수님의 수업을 듣고 정리한 내용입니다.

1. 프로세스와 스레드

프로세스는 초기에 하나의 시작 스레드를 가진다

스레드는 다른 스레드를 만들 수 있다

스레드 생성은 프로그래머가 지시한다

모든 스레드는 자신 고유의 스택을 가지고 있고, Data, Heap, Code 영역은 공유한다.

스레드는 CPU에서 하드웨어적으로 관리된다 (x86)

?? 옆 스레드의 stack은 접근 불가인가?? – 가능하지만 하지 않는다.

옛날에는 스레드란 개념이 없고 프로세스라는 개념만 있었다. 마치 옛날 수학 시간엔 1,2,3,4만 자연수였는데 요즘은 0까지 포함되는 것과 같다. 초반엔 스레드 없이 모든 것을 다 설명했다. 프로세스에는 스레드 하나씩 있다고 하는 게 좀 더 일관성이 있다. 프로세스는 스레드를 가지고 있다. 그러니까 스레드는 프로세스의 부분집합이다. 프로세스는 스레드 하고 다른 면목들이 모여서 프로세스를 이룬다.

컴퓨터를 실행할 때 컴퓨터를 켜놓고 보면 작업관리자에 프로세스가 많이 생기는 것을 알 수 있다. 이 프로세스는 누가 만든 것일까? 프로세스가 만든 것이다. 어떤 실행되는 프로세스는 다른 프로세스가 CreateProcess같은 시스템 콜로 프로세스를 만든 것이다. 스레드도 마찬가지로 스레드가 여러 개 있는 게 멀티스레드인데, 그 스레드는 또 다른 스레드가 만든다. 맨 처음 태초에 하나의 스레드는 디폴트로 생기는 것이고, 다른 스레드는 CreateThread로 생기는 것이다. 즉, 이 함수를 9번 호출하면 스레드는 10개인 것이다.

착각하는 사람이 많은데, 실행파일이 있으면 그 실행파일 안에 이 파일은 4개의 스레드로 운영된다고 속성을 넣어 놓는 것이 아니다. 운영체제는 스레드가 몇 개 생길지 모른다. 실행해봐야 안다. 함수로 스레드를 만들 때만 생기기 때문이다.

스레드가 스레드를 만들면 무슨 일이 있을까? 이런 일이 있다. 시작 스레드, 즉 부모 스레드가 실행이 되고 코드 데이터 힙 스택이 사용된다. 처음 실행되는 하나의 스레드는 프로세스와 같다. 그런데 실행을 하면서 스레드 생성이라는 시스템 콜을 코드에서 호출하면 자식이 생기고, 또 호출하면 자식이 생긴다. 이랬을 때 자식 스레드는 어떤 환경에서 실행되느냐? 스택은 별도로 생성이 된다. 따로따로 스레드마다. 그런데 코드, 데이터, 힙은 애매하다. 똑같은 메모리를 같이 쓰게 된다. 그 이야기는 부모스레드가 실행하는 프로그램과 자식 스레드가 실행하는 프로그램은 같은 프로그램이다. 코드를 복사해서 놓고 쓰는 것이 아니라, 그냥 한 장소에 있는 것을 자식과 부모가 같이 쓰는 것이다. 그래서 자식 스레드의 코드, 데이터, 힙은 실체가 없고 모두가 공유하여 같이 쓰는 것이다.

가상메모리 공간이면 원래 공유 영역에 코드, 데이터, 힙이 있다. 스레드를 만들면 자식의 스택만 새로 생기고 다른 건 공유를 해서 같이 돌아간다. 이게 스레드의 특징이다. 프로세스는 부모랑 완전히 독립되어서 분리되는데 스레드는 아니다.

스레드는 CPU에서 하드웨어적으로 관리되는데 CPU안에 자료구조가 있다. 사실 CPU 안은 아니고 메모리 안에. 부모 스레드가 실행하고 데이터에 전역 변수 내용을 고친 뒤, 자식이 데이터를 읽으면 바뀐 값이 읽힌다. 모든 스레드가 마찬가지. 굳이 바꾼 다음에 ‘나 바뀌었어’ 하고 알려주는 것이 아니다. 복사하는 것이 아니기 때문이다. 그러나 스택은 안 바뀐다. 지역변수의 수정은 반영되지 않는다는 뜻이다.

스레드 사이에 데이터를 주고받아야 한다. 그러려면 어떡해야 하는가? 메모리를 통해서 서로 데이터를 쓰고 읽고 해야한다. 데이터를 읽고 ‘그렇다면 나의 값은 이것이다’ 하고 데이터를 쓰고 읽고 주고받고… 이것이 전역 변수 데이터 영역이다.

스택은 무엇인가? 학생 때 부모님과 함께 산다고 잠까지 같이 자지는 않는다. 독립된 방에서 잤다. 스레드도 마찬가지이다. 모두 공유되면 똑같은 일밖에 할 수 없다. 공유가 되긴 하는데 똑같은 일을 하게된다면 의미가 없다. 그래서 개인작업공간을 분리시킨 것이 스택이다. 지역변수는 서로 볼 수 없고 수정할 수 없다. 그러나 전역변수는 공유할 수 있다.

더 자세히 들어가면, 데이터 영역에 특별한 키워드를 쓰면 독립적으로 쓸 수 있다. 그러나 스택은 공유 그런 것이 없는데 억지로 자식의 방에 들어가서 컴퓨터를 살펴보겠다고 한다면 할 수는 있다. 그러나 좋은 일이 아니다. 사람들 사이에서는 관계가 틀어지고 프로그램 사이에는 버그가 나기 쉽다.

2. 프로세스에 대한 스레드의 장점과 단점

장점 생성 Overhead가 적다 Context Switch Overhead가 적다 ( Virtual memory (TBL교체 오버헤드)) Thread 간의 통신이 간단하다.

단점 하나의 스레드에서 발생한 문제가 전체 프로세스를 멈추게 한다 디버깅이 어렵다

프로세스는 할 수 없고 스레드만 할 수 있는 것이 있는가? 있다고 할 수 있지만, 절대 그런 것은 아니다. 절대 스레드만 할 수 있고, 절대 프로세스는 할 수 없다 이런 것은 없다. 스레드가 할 수 있는 것은 프로세스도 할 수 있고, 프로세스가 할 수 있는 것은 스레드도 다 할 수 있다.

그럼 다 멀티프로세스로 해서 성능을 올리지 왜 멀티스레드를 쓰는가?

이런 장점이 있다. 첫번째로 생성 오버헤드가 적다. 스레드를 만들 때 시스템 콜을 해야 하는 데 걸리는 시간이 프로세스보다 적다. 금방 만들 수 있다. 오버헤드는 자원, 즉 메모리를 줘야 한다. 메모리를 할당시켜줘야 하고 파일 i/o자원도 할당을 해주어야 한다. 왜냐하면 printf scanf 하는 것도 자원이기 때문이다.

그러나 스레드는 그렇지 않다. 같은 집에서 살기 때문에 새 집, 즉 메모리를 줄 필요가 없다. 그래서 오버헤드가 적다.

둘째로 스레드와 스레드 간의 context switch가 가볍다. 빠르다는 이야기이다. 왜? virtual memory이기 때문이다. 가상 메모리 맵핑을 딱딱 바꾸어야 한다. 그러나 스레드는 그럴 필요가 없다. 스레드는 가상 메모리 맵핑 그대로 공유하니까 교체하지 않고 그대로 실행하면 된다. 교체 오버헤드도 적고, 스위치를 하면 실행되는 프로세스가 다른 프로세스를 실행하니까 전에 프로세스가 사용하는 것은 완전히 다른 메모리이다. 이럴 경우 캐시 미스(Cache Miss)가 난다. 여태까지 사용한 데이터는 날아가고 새로 해서 캐시 미스가 계속 난다. 그런데 멀티스레드를 하면 캐시 미스가 덜 일어난다. 그래서 스위치 오버헤드가 적다.

셋째로 가장 큰 이유는 스레드간 통신이 간단하다. 얼마나 간단한가?

다른 스레드간 통신을 하고 싶다 그러면 IPC(Inter Process Communication)이 된다. 프로그래밍 언어에는 없고 운영체제마다 다 다른 API를 제공한다. 윈도우는 윈도우에 있는 IPC를 써야 하고, 리눅스는 리눅스에 있는 IPC를 써야 한다. 프로세스끼리 데이터를 주고받고 싶으면 소켓을 만들어서 소켓 IPC를 해야 한다. 그러면 같은 컴퓨터에 프로세스가 있지 않아도 된다. 다른 컴퓨터에 있는 프로세스끼리도 통신할 수 있다. 그런 엄청난 장점이 있지만, 오버헤드가 크고 느리다.

그렇다면 스레드 통신은 어떻게 하냐? 통신이 아니다. 스레드A가 스레드 B에게 3이라는 데이터를 보내고 싶으면 A는 3을 보낸다. 그럼 A에선 num = 3; 그리고 B에서는 cout << num; 하면 받을 수 있다. 여러 세팅이 필요한 것이 아니다. 그러나 단점도 있다. 첫번째로 멀티 프로세스로 했을 때 한 프로세스가 죽으면 다른 프로세스들이 다 죽지 않는다. 그리고 프로세스가 실행될 때 그 옆 프로세스가 죽으면 다시 깨우고 연결해서 실행하면 된다. 그러나 스레드는 그렇지 않다. 스레드가 실행되다 죽는다면? 그러면 스레드 전체가 죽는다. 어떤 스레드에서라도 크러쉬가 나서 종료한다고 하면 전체 스레드가 죽는 것이다. 다시 살릴 방법이 없다. 문제가 발생했을 때 파급효과 페널티가 치명적이다. 이것이 단점이다. 두 번째 단점은 디버깅이 어렵다는 점이다. 왜 어려울까? 프로그램은 하나인데 이 안에서 프로그램 안에 여러 군데가 동시에 실행되고 있기 때문에, 에러가 나면 이 위에서 무슨 일이 벌어졌나 살펴보게 된다. 그런데 그게 불가능하다. 왜냐하면 내가 온 길은 보이지만, 옆에 스레드가 어디를 실행하고 있는지 보이지 않는다. 억지로 볼 수는 있겠지만 명령어가 어떤 순서로 일어나는지 알 수 없다. A에 3이 들어가며 안되는데 들어갔다? 그럼 누가 3을 넣었는지 알 수가 없다. 이게 제일 큰 단점이다. 이것만 아니라면 멀티스레드가 널리 퍼져서 C나 C++ 배울 때 쓰라고 했을 텐데 그러지 못하는 이유는 디버깅이 너무 어렵기 때문이다.

초보 탈출 #2 – 멀티 스레드 프로그래밍 1

# 초보 탈출 #2 – 멀티 스레드 프로그래밍 1

# 핵심 강의

# 강의 개요

멀티 스레드가 필요한 가장 큰 이유는 프로그램의 실행 효율을 높이기 위해서입니다. 그러나, 스레드를 이용하면 코드가 복잡해지고 디버깅하기가 까다로워 집니다. 그리고 오히려 성능을 해치는 경우도 발생합니다. 이 강의에서는 기초적인 스레드 사용의 패턴을 통해서 효과적으로 스레드를 사용할 수 있는 방법들을 알아봅니다.

제가 생각하는 중급으로 넘어가기 위한 장벽들입니다. 기초 알고리즘

OOP

멀티 스레드 (비동기 프로세스)

포인터

함수 호출의 구조 (재귀 프로세스 등)

# 강의 전 준비 사항

Visual Studio 2015 Update 3 또는 이후 버전

git

https://github.com/ryujt/multi-thread-exapmle (opens new window) 예제 다운로드

# 이 강의에서 다룰 내용

이 강의에서는 이미 만들어진 라이브러리를 활용하여 구체적인 구현보다는 논리적인 개념을 파악하는데 집중하겠습니다.

스레드 프로그래밍의 기초

스레드를 사용하는 패턴들

TIP 보편적인 방법을 사용하지 않고 제가 미리 만든 라이브러리를 이용한 이유가 있습니다. 초보자분들에게 멀티 스레드를 강의할 때마다 초기에 미리 알아야할 정보들이 너무 많아서 호기심을 잃기도 하고, 전체의 내용을 이해하는데 방해가 되는 경우가 있었습니다. 그래서 미리 만든 라이브러리를 방편(方便)으로 삼아서 단계적으로 이해를 이끌어 내려고 합니다.

# 스레드 프로그래밍의 기초

스레드는 일종의 분신술입니다. 프로그램이 실행되면 OS로부터 프로세스가 생성됩니다. 이는 마치 OS가 분신을 만들어서 자신은 원래 하던 일을 계속하고, 새로 만들어진 분신이 프로그램의 내용을 읽고 그대로 임무를 수행하는 것과 같습니다.

그런데 그 분신인 프로세스마저 임무가 복잡해서 분신술이 필요할 수 있습니다. 이렇게 프로세스가 새로 만든 분신을 스레드(thread)라고 할 수 있습니다.

TIP 위의 설명은 상당히 단순한 관점에서의 표현입니다. 프로세스와 스레드의 정확한 차이와 이해가 필요하신 분들은 추가로 검색해보시길 권합니다.

# 스레드의 장점

동시에 처리해야 할 일이 있을 때

CPU 성능을 최대한 활용 할 수 있음

프로세스보다 빠르고 적은 비용

대기시간 및 응답시간 최소화

독립 실행되는 모듈을 만들어서 시스템을 단순하게 만들 수 있다

# 스레드의 단점

코드의 난이도 증가

에러의 위험성 증가 및 디버깅이 어려워진다.

멀티 스레드 프로그램을 하다보면 각기 다른 스레드 들이 동시에 특정 자원을 사용하려고 하는 순간이 발생합니다.

아래와 같은 단순한 예를 들어보겠습니다. Thread 1은 data 값을 0부터 1씩 더해가며 작업 중입니다. 그런데 중간에 Thread 2가 data 값을 0으로 변경하였습니다. Thread 1은 data의 값이 3이 될 것을 기대하고 작업하고 있는데, 전혀 엉뚱한 값이 들어가서 논리적으로 결함이 발생합니다.

가장 심각한 상황 중에 하나는 특정 스레드가 할당 받은 메모리 공간에서 작업하고 있는 동안, 다른 스레드가 메모리 할당을 해제하는 경우입니다. 이것은 마치 의자가 있는 것을 확인하고 앉으려는 순간 장난 꾸러기 친구가 의자를 치워버린 경우와 같습니다.

프로그래밍에서는 이런 경우를 A.V.에러(Access Violation error)라고 합니다.

이렇게 여러 개의 스레드가 같은 자원을 공유하면서 발생하는 문제들은 임계영역을 사용하여 해결할 수 있습니다. 임계영역은 일종의 신호등이며 자물쇠의 역활을 합니다. 그래서 임계영역을 락(lock)이라고도 부릅니다. 어떤 자원을 사용할 때, 사용중인 스레드가 락을 걸어서 다른 스레드는 접근할 수 없도록 하는 것입니다. 자원 사용을 마친 스레드가 락을 풀어주면 다른 스레드가 다시 락을 걸어서 사용하게 됩니다.

임계영역을 사용하면 자신이 락을 걸고 데이터를 사용하는 동안은 다른 스레드에 의해서 데이터가 오염되는 것을 방지 할 수 있습니다.

하지만, 임계영역을 사용하면 복수의 스레드가 한정된 자원으로 인해서 대기하는 시간이 길어지면서 단일 스레드보다 효율이 떨어지는 병목현상이 발생하기도 합니다. 어떤 조건에서는 서로 락을 걸고 풀어주지 않아서 시스템이 멈출 수도 있습니다. 이런 경우는 데드락(Deadlock)이라고 합니다. 그래서 락을 사용하는 경우에는 스레드를 효율적으로 사용하기 위해 다양한 해법이 필요합니다.

SimpleThread 클래스는 제가 자주 사용하는 기능들을 포함시킨 스레드 래핑(Wrapping) 클래스입니다. 소스는 https://github.com/ryujt/multi-thread-exapmle (opens new window)에서 다운받을 수 있습니다.

# 동시에 두 가지 일을 하기

# include # include int main ( ) { SimpleThread thread ( [ & ] ( SimpleThread * simple_thread ) { while ( true ) { printf ( “Hello from thread.

” ) ; simple_thread -> sleep ( 1000 ) ; } } ) ; while ( true ) { printf ( “Hello from main.

” ) ; Sleep ( 1000 ) ; } } 1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

2: SimpleThread를 가져와서 스레드를 쉽게 처리할 수 있도록 합니다.

6-11: SimpleThread 객체를 생성하고 스레드로 실행할 코드를 7-10에 구현하였습니다. 7: 스레드가 중단되지 않고 계속 실행하도록 무한 반복합니다. 8: 스레드가 동작 중임을 표시하기 위해서 콘솔에 메시지를 출력합니다. 9: 1초(1000ms) 동안 기다립니다. 15: 라인처럼 Sleep(1000)을 사용해도 됩니다. 차이점은 simple_thread->sleep(1000)은 기다리는 도중에도 외부에서 신호를 줘서 깨울 수가 있습니다.

13-16: 프로그램이 종료되지 않도록 무한 반복하면서 기다립니다. 프로그램이 종료되면 스레드가 돌고 있는 상황에서도 프로그램이 종료되면서 스레드도 종료됩니다. 14: 메인 스레드가 동작 중임을 표시하기 위해서 콘솔에 메시지를 출력합니다. 15: 1초 기다립니다.

아래는 실행결과입니다. Sleep() 함수의 오차로 인해서 순서가 뒤바뀔 수 있다는 점을 유의하세요.

# 일을 줄 때까지 기다리기

“동시에 두 가지 일을 하기”처럼 항상 동작하는 스레드가 필요한 경우도 있겠지만, 일이 없을 때는 CPU를 사용하지 않고 조용히 쉬게 하고 싶을 때가 있습니다. 이 때 사용할 수 있는 방법은 아래와 같습니다. 외부에서 메시지를 보내서 깨우기 전에는 동면을 하듯이 자고 있다가 메시지를 받았을 때만 일어나서 일을 하는 경우입니다.

# include # include # include using namespace std ; int main ( ) { SimpleThread thread ( [ & ] ( SimpleThread * simple_thread ) { while ( true ) { simple_thread -> sleepTight ( ) ; printf ( “Hello?

” ) ; } } ) ; while ( true ) { string line ; printf ( “Command: ” ) ; getline ( cin , line ) ; if ( line == “q” ) break ; thread . wakeUp ( ) ; } } 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

1: 문자열을 다루기 위해서 추가하였습니다.

11: sleepTight() 메소드는 스레드를 완전히 멈추가 메시지를 기다립니다. 따라서 12: 라인에 있는 prinft() 함수가 실행되지 않습니다.

17-19: 화면에 “Command: “를 표시하고 문자열을 입력받기를 기다립니다. 엔터가 쳐지면 입력된 문자열을 line 변수에 저장합니다.

21: 입력된 문자가 “q”라면 반복을 중단하고 프로그램을 종료합니다.

23: 그 이외의 문자라면 스레드를 깨웁니다.

# SimpleThread를 종료하는 세 가지 방법

SimpleThread를 종료하기 위해서는 아래의 세 가지 방법 중에 하나를 선택하시면 됩니다.

terminate(): isTerminated()가 true가 되도록 합니다. 스레드가 종료되는 과정까지 기다리지 않고 종료하라고 메시지만 전달한 경우입니다.

terminateAndWait(): isTerminated()가 true가 되며, 스레드를 깨우고 종료 될 때까지 기다립니다.

terminateNow(): isTerminated()가 true가 되며, 스레드를 바로 종료합니다. 실행하자마자 스레드가 바로 중단됩니다.

# include # include # include using namespace std ; int main ( ) { SimpleThread thread ( [ & ] ( SimpleThread * simple_thread ) { while ( simple_thread -> isTerminated ( ) == false ) { simple_thread -> sleepTight ( ) ; printf ( “Hello?

” ) ; } printf ( “thread is stopped.

” ) ; } ) ; while ( true ) { string line ; printf ( “Command: ” ) ; getline ( cin , line ) ; if ( line == “q” ) break ; if ( line == “t” ) thread . wakeUp ( ) ; if ( line == “t” ) { thread . terminate ( ) ; break ; } if ( line == “tw” ) { thread . terminateAndWait ( ) ; break ; } if ( line == “tn” ) { thread . terminateNow ( ) ; break ; } } printf ( “programm is about to close.

” ) ; } 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

32

33

34

35

36

37

38

39

40

41

42

43

44

10: 무한 반복이 아닌 simple_thread->isTerminated()가 true 일 때까지만 반복하는 것으로 바뀌었습니다.

# terminate()로 종료했을 때

terminate() 메소드는 블록킹을 하지 않기 때문에 바로 리턴이 되면서 “”programm is about to close.”이 콘솔에 바로 찍히게 됩니다. 그리고 프로그램이 먼저 종료되면서 “thread is stopped.”는 화면에 나타나지 않은 것을 확인할 수가 있습니다.

# terminateAndWait()로 종료했을 때

스레드 내부의 코드가 모두 동작하는 것을 기다렸기 때문에 “programm is about to close.” 이전에 스레드에서 출력한 모든 메시지가 콘솔에 표시된 것을 확인할 수가 있습니다.

# terminateNow()로 종료했을 때

스레드를 바로 강제 종료하였기 때문에 스레드 쪽에서는 아무런 메시지를 찍어보지 못하고 프로그램 종료 메시지가 표시 된 것을 확인할 수가 있습니다.

terminateNow()는 프로그램이 완전히 종료될 때 주로 사용합니다. 프로그램이 종료될 때에는 스레드가 사용하던 리소스를 반환하거나 정리할 필요 없기 때문입니다.

스레드는 종료 처리가 어려울 때가 많은데요, 프로그램이 종료하면서 사용 중이던 리소스를 이미 반환했는데, 스레드가 늦게 깨어나면서 리소스에 접근하려고하다가 오류가 발생하는 경우가 가끔 일어납니다. 이럴 때는 terminateNow()로 스레드를 바로 종료시켜버리면 프로그램 종료 중에 스레드가 뒤늦게 깨어나서 충돌하는 일을 방지 할 수 있습니다.

Producer-consumer pattern(생산자-소비자 패턴)은 스레드를 사용하는 가장 흔한 케이스 중 하나입니다. 생산자가 일거리를 만들면 소비자가 일을 가져와서 처리하는 것입니다.

식당에서 손님의 주문을 받아서 주문서 철에 하나씩 추가하는 과정을 예로 들겠습니다. 이때 주문을 받아서 철에 추가하는 사람은 생산자에 해당합니다.

주방에서 요리사가 철에 쌓여져 있는 주문서 중에서 하나를 가져가서 주문서대로 요리를 만들게 됩니다. 이때 요리사는 소비자에 해당합니다.

생산자: 일을 만드는 넘

소비자: 일을 가져가는 넘

일을 가져가서 처리하는 소비자 스레드가 아직 일을 마무리하지 않았는데, 생산자가 일을 더 주려고하면 어떻게 될까요? 일이 꼬이거나 아니면 생산자는 소비자가 일을 마칠 때까지 다른 일은 못하고 계속 기다려야 할 것입니다. 그래서 일거리를 쌓아 둘 수 있는 큐를 만들고 생산자는 큐에 일을 추가하고, 소비자는 일을 가져가서 처리합니다.

생산자-소비자 패턴은 아래의 다이어그램처럼 간단한 구조를 가지고 있습니다.

Producer는 처리해야 할 일(task)이 발생하면 queue에 쌓아(push) 둡니다.

Consumer는 queue에서 일을 가져와서(pop) 처리합니다.

처리할 일이 없을(nil) 때는 무시하고 일이 발견될 때까지 반복합니다.

만약 여러분들이 온라인 게임을 만든다고 가정하겠습니다. 전투 중에 얻은 아이템이나 경험치들을 DB에 저장해둬야 합니다. 그런데 DB에 저장하는 속도는 너무 느리기 때문에 게임 로직을 처리하는 스레드에서 직접 처리하면, 저장하는 동안 게임이 멈추는 등의 현상이 발생하게 됩니다. 이때 게임 로직을 처리하는 스레드는 DB에 저장할 정보를 queue에만 쌓아두고 제 할일을 계속 하고, DB 저장용 스레드가 백그라운드로 처리해준다면 DB 저장 속도 때문에 게임이 멈추는 일은 없을 것 입니다.

TIP 생산자도 소비자도 여러 개(스레드)일 수 있습니다.

ThreadQueue의 메소드들은 락을 사용하여 여러 개의 스레드가 동시에 접근해도 순차적으로 진행되도록 구현되어 있습니다.

# include # include # include # include using namespace std ; int main ( ) { ThreadQueue < string > que ; SimpleThread producer ( [ & ] ( SimpleThread * simple_thread ) { while ( simple_thread -> isTerminated ( ) == false ) { que . push ( “task” ) ; printf ( “task has produced.

” ) ; simple_thread -> sleep ( 1000 ) ; } } ) ; SimpleThread consumer ( [ & ] ( SimpleThread * simple_thread ) { while ( simple_thread -> isTerminated ( ) == false ) { string item = “” ; if ( que . pop ( item ) ) { item = item + ” –> used ” ; printf ( “%s

” , item . c_str ( ) ) ; } else { simple_thread -> sleep ( 1 ) ; } } } ) ; while ( true ) { Sleep ( 1000 ) ; } } 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

32

33

34

35

처리해야할 일(task)을 저장해 둘 큐(ThreadQueue) 클래스가 정의된 헤더입니다. 멀티 스레드에서 사용할 수 있도록 설계되어 있습니다.

12-18: 생산자 스레드를 생성하고 1초마다 “task”를 만들어서 큐에 저장합니다. 14: “task” 문자열을 큐에 넣습니다. 이것을 처리해야 할 일이라고 가정하겠습니다. 15: 콘솔에 작업이 추가되었음을 표시합니다. 16: 다음 작업을 만들 때까지 1초 기다립니다.

20-30: 소비자 스레드를 생성하고 0.001초마다 일을 가져와서 처리합니다. 0.001초보다 작거나 커도 되지만, 생산자보다 더 긴 간격으로 일을하게되면 일이 점점 밀리게 됩니다. 어떤 때에는 간격없이 바로바로 처리해도 시간이 많이 걸리는 일이어서 밀릴 수도 있습니다. 이런 경우에는 소비자 스레드를 여러 개로 늘려서 처리할 수도 있습니다. 23: 큐에서 일을 하나 가져옵니다. 일이 없으면 pop() 메소드의 결과가 false가 되어 24-25:는 실행되지 않습니다. 24: 가져온 일을 사용(처리)합니다. 여기서는 뒤에 “–> used”를 붙이는 것으로 일이 처리 된 것으로 표현하였습니다. 25: 처리된 일을 콘솔에 표시합니다. 27: 없어도 상관은 없습니다. (기다리지 않고 계속 실행해도 됩니다)

27: 라인은 없어도 되지만 할 일이 없는데도 쉬지 않고 계속 일을 찾다보면 아래 그림의 오른쪽처럼 쓸 때없이 CPU를 많이 사용하게 됩니다.

# Guarded suspension pattern (SuspensionQueue)

생산자-소비자 패턴의 경우에는 일이 없을 때에는 다른 일을 처리할 수는 있겠지만, 큐 안에 쌓인 일만 하는 경우에는 일이 없어도 반복하면서 CPU 자원을 낭비하게됩니다. 그와 달리 Guarded suspension 패턴은 일이 없으면 스레드가 완전히 멈춰서 기다립니다. 다시 일이 들어와서 자신을 깨울 때까지 완전히 멈추게 됩니다.

TIP 다른 일도 하면서 큐에 있 데이터가 있으면 병행해서 처리해야 한다면 ThreadQueue를 사용하고, 큐에 있는 일만 처리하면 될 경우에는 SuspensionQueue 사용하세요.

SuspensionQueue의 메소드들은 락을 사용하여 여러 개의 스레드가 동시에 접근해도 순차적으로 진행되도록 구현되어 있습니다.

# include # include # include # include using namespace std ; int main ( ) { SuspensionQueue < string > que ; SimpleThread producer ( [ & ] ( SimpleThread * simple_thread ) { while ( simple_thread -> isTerminated ( ) == false ) { que . push ( “task” ) ; printf ( “task has produced.

” ) ; simple_thread -> sleep ( 1000 ) ; } } ) ; SimpleThread consumer ( [ & ] ( SimpleThread * simple_thread ) { while ( simple_thread -> isTerminated ( ) == false ) { string item = que . pop ( ) ; item = item + ” –> used ” ; printf ( “%s

” , item . c_str ( ) ) ; } } ) ; while ( true ) { Sleep ( 1000 ) ; } } 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

코드는 생산자-소비자 패턴과 거의 똑같은 것을 아실 수가 있습니다. 중요한 차이는 22: 라인에 있습니다. SuspensionQueue의 pop() 메소드는 큐 안에 아무것도 없으면 리턴되지 않고 멈춰서게 됩니다. (blocking) 따라서, sleep() 메소드를 사용하지않아도 CPU를 계속 사용하면서 반복하는 일이 없습니다. 또한 큐에서 일을 가져왔는 지 if 문을 사용해서 처리할 필요가 없습니다. 일이 없다면 다음 코드가 실행 될 일이 없기 때문입니다.

Scheduler는 생산자-소비자 패턴과 스레드를 합쳐 놓은 형태입니다. add() 메소드를 이용해서 처리해야 할 일을 추가하면 OnTask 이벤트가 발생합니다. OnTask는 Scheduler 내부 스레드에 의해서 동작하기 때문에 병렬로 처리됩니다. 그리고, 처리할 일이 없어도 OnRepeat 이벤트가 계속 발생하는데요, 주기적으로 처리해야 할이 있는 경우 사용합니다.

아래 코드는 가상으로 동작하는 소켓 프로그램을 예로 들어 본 것 입니다.

start(): Scheduler 내부 스레드에 의해서 작업(task)과 이벤트가 처리가 시작됩니다.

stop(): Scheduler 내부 스레드에 의해서 작업(task)과 이벤트가 중단 됩니다.

add(): 처리해야 할 일을 추가합니다.

OnTask: Scheduler의 add() 메소드에 의해서 작업(task)가 추가되면 동작하는 이벤트입니다. 이벤트 핸들러의 코드는 Scheduler 내부 스레드에 의해서 실행됩니다.

OnRepeat: 계속 반복해서 주기적으로 실행됩니다.

# include # include # include const int TASK_CONNECT = 1 ; const int TASK_DISCONNECT = 2 ; class Address { public : Address ( string ip , int port ) : ip_ ( ip ) , port_ ( port ) { } string ip_ ; int port_ ; } ; int main ( ) { Scheduler scheduler ; scheduler . setOnTask ( [ ] ( int task , const string text , const void * data , int size , int tag ) { switch ( task ) { case TASK_CONNECT : { Address * address = ( Address * ) data ; printf ( “Connect to %s:%d

” , address -> ip_ . c_str ( ) , address -> port_ ) ; delete address ; break ; } case TASK_DISCONNECT : { printf ( “Disconnect

” ) ; break ; } } } ) ; scheduler . setOnRepeat ( [ & ] ( ) { printf ( “수신된 메시지 확인…

” ) ; scheduler . sleep ( 1000 ) ; } ) ; scheduler . start ( ) ; while ( true ) { string line ; printf ( “Command: ” ) ; getline ( cin , line ) ; if ( line == “c” ) scheduler . add ( TASK_CONNECT , new Address ( “127.0.0.1” , 1234 ) ) ; if ( line == “d” ) scheduler . add ( TASK_DISCONNECT ) ; } } 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

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

47: 소켓을 접속하라는 작업을 추가합니다. 그러면 바로 setOnTask에 정의된 이벤트 핸들러의 코드가 스레드에서 실행됩니다. add(int task, void* data)

48: 소켓 접속을 종료하라는 작업을 추가합니다. 추가 정보가 필요없을 때에는 작업 내용만 추가하면 됩니다. add(int task)

21-35: 작업이 추가되었을 때 실행되는 코드입니다. task_type 종류에 따라서 각각의 의미에 맞는 코드를 실행하면 됩니다. 24: 라인에서는 data 파라메터에 전달된 주소 객체를 타입 변환을 하여 사용하고 있습니다.

36-39: 무한 반복되는 코드입니다.

add() 메소드의 파라메터 void add ( int task ) void add ( int task , string text ) void add ( int task , void * data ) void add ( int task , string text , void * data , int size , int tag ) 1

2

3

4

Worker 클래스는 Scheduler 클래스와 비슷합니다. 차이점은 OnRepeat 이벤트가 없다는 점입니다. Worker는 일을 주지 않을 때는 내부 스레드가 완전하게 멈춰있게 됩니다. Guarded suspension 패턴에 필요한 요소를 하나의 클래스 안에 담아둔 형태입니다.

게임 서버가 동작하는 중에 DB에 데이터를 저장해야하는 작업이 있다고 가정하겠습니다. 그때 저장해야할 데이터를 json 문자열로 Worker에 add() 해주고, OnTask 이벤트에서 DB 저장 코드를 넣어주면 비동기로 DB를 저장하는 코드를 쉽게 작성할 수 있습니다.

# include # include # include int main ( ) { Worker worker ; worker . setOnTask ( [ ] ( int tast , const string text , const void * data , int size , int tag ) { printf ( “OnTask: %s

” , text . c_str ( ) ) ; } ) ; while ( true ) { string line ; printf ( “Command: ” ) ; getline ( cin , line ) ; worker . add ( line ) ; } } 1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

스레드로 실행되는 코드에서 printf()를 함께 사용하기 때문에 이전 예제처럼 문자열은 서로 겹쳐서 표시되는 경우가 많습니다.

10주차 과제 : 멀티쓰레드 프로그래밍

멀티쓰레드 프로그래밍

자바에서 제공하는 멀티쓰레드 프로그래밍에 대해 공부해보자 📖

Thread 클래스와 Runnable 인터페이스

쓰레드의 상태

쓰레드의 우선순위

Main 쓰레드

동기화

데드락

지난 9주차 과제 회고 ✍️

Exception을 계속 throw 하다보면 결국 main 메소드에서 처리를 해야 하는데, 여기서도 던지면 jvm이 어떤 방식으로 throw를 처리하는지에 대한 질문이였다.

간단하게 정리하자면, 해당 쓰레드는 예외를 던지고 종료가 된다.

하필 이번주 스터디 주제가 멀티쓰레드 프로그래밍이기 때문인진 몰라도 저 의미가 너무나도 궁금했다. 🤔

TMI : 이번에 작성한 예제는 필자가 맥날 알바생이기 때문에 관련 예제가 햄버거와 관련되어있습니다..

프로세스(Process) 📌

실행중인 프로그램을 뜻한다. 쉽게 확인할 수 있는 방법은 작업 관리자에 들어가 프로세스 텝을 확인하면 내 컴퓨터에 얼마나 많은 프로세스들이 있는지 확인할 수 있다.

쓰레드(Thread) ⭐️

프로세스 내에서 실행되고 있는 흐름의 단위이다.

멀티태스킹(Multi-tasking) ⭐️

여러 개의 프로세스를 동시에 실행하는 것을 의미한다. 현재 사용하고 있는 윈도우나 맥 OS 처럼 여러 개의 프로그램을 동시에 사용할 수 있는 이유가 바로 멀티태스킹 환경을 지원해주고 있기 때문이다.

멀티쓰레드(Multi-Thread) ⭐️

하나의 프로세스 안에 여러 개의 쓰레드가 있는 것을 멀티쓰레드라고 한다. 이해하기 쉽게 표현하자면 필자가 알바하는 맥도날드로 설명해보겠다. 빅맥 세트를 주문하면 주문과 동시에 버거는 그릴에서 만들어지고, 음료는 카운터, 감자튀김은 튀김기 앞에서 동시다발적으로 만들어진다. 즉, 주문이라는 프로세스 안에 햄버거, 음료, 감자튀김이 만들어지는 흐름이 생성되는 것이다.

멀티쓰레드의 장점

CPU의 사용률을 향상시킨다.

자원을 보다 효율적으로 사용할 수 있다.

사용자에 대한 응답성이 향상된다.

작업이 분리되어 코드가 간결해진다.

멀티쓰레드의 단점

여러 개의 쓰레드가 같은 프로세스 내에서 자원을 공유하면서 작업하기 때문에 동기화(synchronization) , 교착상태(deadlock) 와 같은 문제가 발생할 확률이 높다.

Thread 생성하기 ✍️

자바에선 쓰레드를 생성하는 방법이 2가지가 있다. 하나는 Thread 클래스를 상속받아서 사용하는 것이고, 나머지 하나는 Runnable 인터페이스를 구현하는 방법이다.

Extends Thread

class Hamburger extends Thread { @Override public void run ( ) { super . run ( ) ; System . out . println ( “Hamburger 나왔습니다.” ) ; } } public class ThreadExample { public static void main ( String [ ] args ) { Hamburger hamburger = new Hamburger ( ) ; hamburger . start ( ) ; } }

Hamburger 나왔습니다 .

implements Runnable interface

class Hamburger implements Runnable { @Override public void run ( ) { System . out . println ( “Hamburger 나왔습니다.” ) ; } } public class MultiThreadExample { public static void main ( String [ ] args ) { Thread hamburger = new Thread ( new Hamburger ( ) ) ; hamburger . start ( ) ; } }

Hamburger 나왔습니다 .

조금 더 생산적인 방법 💡

Thread 클래스를 상속받아서 만들거나, Runnable 인터페이스를 구현하여 만들 수 있는 것도 배웠다. 그렇다면 이 두 가지의 방법중 어떤 방법이 조금 더 좋을까? 개인적인 생각이지만 어떻게 쓰레드를 이용할 것인가에 따라 다른 것 같다. 즉 개발환경에 맞게 사용해야 한다고 생각한다. 하지만 대부분의 사람들이 Runnable 인터페이스를 구현하는 방식을 선택한다. 그 이유는 아마도 클래스의 상속을 여전히 사용할 수 있기 때문에 더 선호하지 않을까?라는 생각을 조심스럽게 해본다. 🤔

start(), run() ⭐️

start() -> 새로운 쓰레드가 작업을 실행하는데 필요한 호출스택을 생성하는 것

run() -> start()로 생성된 호출스택에 run()가 첫 번째로 저장되는 과정

조금 더 쉽게 설명하자면 빅맥 세트를 시켰을 때, 햄버거는 그릴에서, 음료수는 카운터에서, 감자튀김은 튀김기 앞에서 만들어진다고 했었다. 이와 마찬가지로 모든 쓰레드는 독립적인 작업을 수행하기 위해 자신만의 호출스택(그릴, 카운터, 튀김기)을 필요로 한다. 그 후 주문한 순서에 맞게끔 번갈아 가면서 음식을 제조하면 된다.

Thread State ⭐️

자바에선 쓰레드의 상태를 6가지의 상태로 정의하였으며, 열거형으로 제공하고 있다. (스스로 번역한 거라 의미에 차이가 좀 있습니다..😅)

NEW A thread that has not yet started is in this state

RUNNABLE A thread executing in the Java virtual machine is in this state

BLOCKED A thread that is blocked waiting for a monitor lock is in this state

WAITING A thread that is waiting indefinitely for another thread to perform a particular action is in this state

TIMED_WAITING A thread that is waiting for another thread to perform an action for up to a specified waiting time is in this state.

TERMINATED A thread that has exited is in this state.

Thread 클래스에서 제공하는 메소드인 getState() 를 이용하면 현재 쓰레드의 상태를 알 수 있다.

이미지 출처 : Thread State

New

쓰레드가 아직 시작하지 않은 상태를 의미한다.

class Hamburger implements Runnable { @Override public void run ( ) { System . out . println ( “Hamburger 나왔습니다.” ) ; } } public class MultiThreadExample { public static void main ( String [ ] args ) { Thread hamburger = new Thread ( new Hamburger ( ) ) ; System . out . println ( hamburger . getState ( ) ) ; hamburger . start ( ) ; } }

NEW Hamburger 나왔습니다 .

RUNNABLE

쓰레드가 자바 가상 머신에서 실행 대기 중이거나 실행 중인 상태를 의미한다.

class Hamburger implements Runnable { @Override public void run ( ) { System . out . println ( “Hamburger 나왔습니다.” ) ; } } public class MultiThreadExample { public static void main ( String [ ] args ) { Thread hamburger = new Thread ( new Hamburger ( ) ) ; System . out . println ( hamburger . getState ( ) ) ; hamburger . start ( ) ; System . out . println ( hamburger . getState ( ) ) ; } }

RUNNABLE이 먼저 출력된 이유는? 🤔

start() 호출한다고 해서 바로 실행되는 것이 아니라 실행대기열에 저장된 후 실행된다. 이때 실행 대기중인 상태기 때문에 RUNNABLE이 먼저 출력되는 것이다.

NEW RUNNABLE Hamburger 나왔습니다 .

BLOCKED

하나의 쓰레드가 동기화 영역에 들어가면 해당 쓰레드가 작업이 종료될 때 까지 동기화 블럭에 접근할 수 없는 상태를 의미한다.

더 쉽게 이야기하면 동기화 블럭에 의해서 일시정지된 상태이다.

class Order implements Runnable { @Override public void run ( ) { makeFood ( ) ; } public static synchronized void makeFood ( ) { while ( true ) { } } } public class MultiThreadExample { public static void main ( String [ ] args ) throws InterruptedException { Thread order = new Thread ( new Order ( ) ) ; Thread newOrder = new Thread ( new Order ( ) ) ; order . start ( ) ; newOrder . start ( ) ; Thread . sleep ( 1000 ) ; System . out . println ( order . getState ( ) ) ; System . out . println ( newOrder . getState ( ) ) ; System . exit ( 0 ) ; } }

RUNNABLE BLOCKED

WAITING

다른 스레드가 특정 작업을 수행하는 중 기존에 작업 중이던 쓰레드가 잠시 멈추는 것을 의미한다.

class OrderEdit implements Runnable { @Override public void run ( ) { try { Thread . sleep ( 3000 ) ; System . out . println ( “주문한 음식에 요청사항이 생겼을 때” ) ; } catch ( InterruptedException e ) { Thread . currentThread ( ) . interrupt ( ) ; e . printStackTrace ( ) ; } System . out . println ( “기존에 주문은 잠시 : ” + WaitingStateExample . order . getState ( ) ) ; } } public class WaitingStateExample implements Runnable { public static Thread order ; public static void main ( String [ ] args ) { order = new Thread ( new WaitingStateExample ( ) ) ; order . start ( ) ; } @Override public void run ( ) { Thread orderEdit = new Thread ( new OrderEdit ( ) ) ; orderEdit . start ( ) ; try { orderEdit . join ( ) ; } catch ( InterruptedException e ) { Thread . currentThread ( ) . interrupt ( ) ; e . printStackTrace ( ) ; } } }

주문한 음식에 요청사항이 생겼을 때 기존에 주문은 잠시 : WAITING

TIMED_WAITING

지정된 시간 내에 다른 쓰레드가 특정 작업을 수행하기를 기다리는 경우

class PresentFood implements Runnable { @Override public void run ( ) { try { Thread . sleep ( 5000 ) ; System . out . println ( “주문하신 음식 나왔습니다.” ) ; } catch ( InterruptedException e ) { Thread . currentThread ( ) . interrupt ( ) ; e . printStackTrace ( ) ; } } } public class TimedWaitingStateExample { public static void main ( String [ ] args ) throws InterruptedException { Thread presentFood = new Thread ( new PresentFood ( ) ) ; presentFood . start ( ) ; Thread . sleep ( 1000 ) ; System . out . println ( presentFood . getState ( ) ) ; } }

TIMED_WAITING 주문하신 음식 나왔습니다 .

TERMINATED

쓰레드가 종료된 상태를 의미한다.

public class TerminatedExample implements Runnable { public static void main ( String [ ] args ) throws InterruptedException { Thread thread = new Thread ( new TerminatedExample ( ) ) ; thread . start ( ) ; thread . sleep ( 1000 ) ; System . out . println ( thread . getState ( ) ) ; } @Override public void run ( ) { } }

TERMINATED

Thread scheduling ⭐️

쓰레드의 상태와 관련된 메소드들이다.

sleep() : 주어진 시간 동안 일시 정지 상태가 되고 다시 실행 대기 상태로 돌아간다.

join() : 특정 쓰레드가 다른 쓰레드의 완료를 기다리게 하는 것

interrupt : sleep(), join()에 의해 일시정지상태인 쓰레드를 실행대기상태로 만드는 것

stop() : 쓰레드를 즉시 종료시킨다.

suspend() : 쓰레드를 일시정지시킨다.

resume() : suspend()에 의해 일시정지상태에 있는 쓰레드를 실행대기 상태로 만드는 것

yield() : 실행 중에 다른 쓰레드에게 양보하고 실행대기상태가 되는 것

resume(), stop(), suspend()는 Thread를 교착상태로 만들기 쉽기 때문에 자바에서 deprecated 시켜버렸다.

Thread Priority ⭐️

특정 작업을 할 때, 다른 작업보다 먼저 처리되어야 하는 경우가 있는데 이때, 우선순위를 설정하여 중요한 작업부터 실행될 수 있게끔 할 수 있다. 예를 들어 맥도날드에서 빅맥 세트를 주문한다고 하면, 햄버거 -> 감자튀김 -> 음료 순으로 나와야 한다.

하지만 이 프로그램은 실행할 때 마다 우선 순위와 상관없이 랜덤으로 만들어진다. 이렇게 되면 어떤 음식이 우선 순위가 가장 높은지 모른다.

public class Mcdonalds { public static void main ( String [ ] args ) { try { HamburgerSetCook hamburgerSetCook = new HamburgerSetCook ( ) ; new Thread ( hamburgerSetCook , “Hamburger” ) . start ( ) ; new Thread ( hamburgerSetCook , “FrenchFries” ) . start ( ) ; new Thread ( hamburgerSetCook , “Drink” ) . start ( ) ; } catch ( Exception e ) { e . printStackTrace ( ) ; } } } class HamburgerSetCook implements Runnable { @Override public void run ( ) { cookingFood ( Thread . currentThread ( ) . getName ( ) ) ; } private void cookingFood ( String name ) { System . out . println ( name + “가 제조되고 있습니다. 잠시만 기다려주세요.” ) ; } }

FrenchFries 가 제조되고 있습니다 . 잠시만 기다려주세요 . Drink 가 제조되고 있습니다 . 잠시만 기다려주세요 . Hamburger 가 제조되고 있습니다 . 잠시만 기다려주세요 .

우선순위 설정 예제

우선순위는 1~10까지 존재하며 설정하지 않은 경우 default 값으로 5이다.

setPriority 메소드를 이용하여 우선순위를 바꿀 수 있다.

하지만 설정한다고 한들, 반드시 우선순위대로 작업한다는 보장은 없다.

public class Mcdonalds { public static void main ( String [ ] args ) { try { Thread hamburger = new Thread ( new HamburgerSetCook ( ) , “Hamburger” ) ; Thread frenchFries = new Thread ( new HamburgerSetCook ( ) , “FrenchFries” ) ; Thread drink = new Thread ( new HamburgerSetCook ( ) , “Drink” ) ; hamburger . setPriority ( 10 ) ; frenchFries . setPriority ( 5 ) ; drink . setPriority ( 2 ) ; hamburger . start ( ) ; frenchFries . start ( ) ; drink . start ( ) ; } catch ( Exception e ) { e . printStackTrace ( ) ; } } } class HamburgerSetCook implements Runnable { @Override public void run ( ) { cookingFood ( Thread . currentThread ( ) . getName ( ) ) ; } private void cookingFood ( String name ) { System . out . println ( name + “가 제조되고 있습니다. 잠시만 기달려주세요.” ) ; } }

Hamburger 가 제조되고 있습니다 . 잠시만 기달려주세요 . FrenchFries 가 제조되고 있습니다 . 잠시만 기달려주세요 . Drink 가 제조되고 있습니다 . 잠시만 기달려주세요 .

Main Thread ⭐️

자바에서 메인 메소드를 통해 프로그램이 실행되면 하나의 쓰레드가 시작되는데 이를 메인 쓰레드 라고 부른다. 여태까지 이 챕터를 공부하지 않았음에도 불구하고 꾸준히 쓰레드를 사용하고 있었다는 뜻이다.

우리가 만든 프로그램을 실행하면 JVM에선 자동으로 메인 쓰레드를 생성해준다.

currentThread() 를 호출하면 해당 쓰레드의 참조값을 가져올 수 있다.

getName()를 호출하면 currentThreadI()로 가져온 현 쓰레드의 Name 값을 알 수 있다.

public class MainThreadTest { public static void main ( String [ ] args ) { Thread thread = Thread . currentThread ( ) ; System . out . println ( thread . getName ( ) ) ; } }

main

동기화 ⭐️

멀티쓰레드 프로그래밍의 특징은 하나의 프로세스를 동시에 여러 쓰레드가 접근한다는 의미이다. 다시 이야기해보면 하나의 데이터를 공유해서 작업한다는 이야기이다.

만약 쓰레드 A가 작업하던 도중에 다른 쓰레드인 B에게 제어권이 넘어가면, 쓰레드 A가 작업하려던 공유데이터를 쓰레드 B가 임의로 변경하게 되고, 이에 따른 결과물이 예상했던 것과 다른 결과가 나올 경우가 있다.

더 쉽게 이야기하자면 한 방아 여러 사람이 컴퓨터 하나를 함께 쓰는 것과 동일하다 A라는 사람이 문서작업 도중 잠시 자리를 비우면 다른 사람이 컴퓨터 앞에 앉아서 문서를 지울 수도 있다는 것이다.

이를 방지하기 위해 특정 쓰레드가 진행중일 때 다른 쓰레드가 접근하지 못하도록 막는 것을 동기화라고한다.

synchronized를 이용한 동기화

자바에서는 synchronzied를 통해 해당 쓰레드와 관련된 공유데이터에 lock을 걸어서 먼저 작업 중이던 쓰레드가 작업을 완전히 마칠 때까지 다른 쓰레드가 접근해도 공유데이터가 변경되지 않도록 보호하는 역할을 한다.

두 가지 방법으로 synchronized 사용하기

특정한 객체에 lock을 걸때

synchronized ( 객체의 참조변수 ) { }

메소드에 lock을 걸때

public synchronized void example ( ) { }

아래에 예제는 실제로 맥도날드에서 일을 하면서 단체 주문으로 버거 10개를 만들라는 지시가 떨어진 상태를 프로그래밍적으로 한번 만들어봤다. 이때 크루들이 동시다발적으로 일하는 상황을 동기화로 작성하였다.

ps. 얄팍한 코딩사전에 나온 예제를 스리슬쩍 바꿔봤다..😅

public class Mcdonalds { public static void main ( String [ ] args ) { try { HamburgerCook hamburger = new HamburgerCook ( 10 ) ; new Thread ( hamburger , “BicMac” ) . start ( ) ; new Thread ( hamburger , “MacChicken” ) . start ( ) ; new Thread ( hamburger , “MacSpicy” ) . start ( ) ; new Thread ( hamburger , “EggBulgogi” ) . start ( ) ; } catch ( Exception e ) { e . printStackTrace ( ) ; } } } class HamburgerCook implements Runnable { private int hamburgerCount ; private String [ ] grill = { “_” , “_” , “_” , “_” } ; public HamburgerCook ( int count ) { hamburgerCount = count ; } @Override public void run ( ) { while ( hamburgerCount > 0 ) { synchronized ( this ) { hamburgerCount — ; System . out . println ( “현재 만들어야 하는 버거의 갯수 : ” + hamburgerCount ) ; } for ( int i = 0 ; i < grill . length ; i ++ ) { if ( ! grill [ i ] . equals ( "_" ) ) { continue ; } synchronized ( this ) { grill [ i ] = Thread . currentThread ( ) . getName ( ) ; System . out . println ( grill [ i ] + "버거를 만드는 중 입니다." ) ; } try { Thread . sleep ( 2000 ) ; } catch ( Exception e ) { e . printStackTrace ( ) ; } synchronized ( this ) { System . out . println ( Thread . currentThread ( ) . getName ( ) + "버거가 다 만들어졌습니다." ) ; System . out . println ( "-------------------------------------------------------" ) ; grill [ i ] = "_" ; } break ; } try { Thread . sleep ( Math . round ( 1000 * Math . random ( ) ) ) ; } catch ( Exception e ) { e . printStackTrace ( ) ; } } } } 현재 만들어야 하는 버거의 갯수 : 9 BicMac 버거를 만드는 중 입니다 . 현재 만들어야 하는 버거의 갯수 : 8 EggBulgogi 버거를 만드는 중 입니다 . 현재 만들어야 하는 버거의 갯수 : 7 MacSpicy 버거를 만드는 중 입니다 . 현재 만들어야 하는 버거의 갯수 : 6 MacChicken 버거를 만드는 중 입니다 . BicMac 버거가 다 만들어졌습니다 . -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- - EggBulgogi 버거가 다 만들어졌습니다 . -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- - MacSpicy 버거가 다 만들어졌습니다 . -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- - MacChicken 버거가 다 만들어졌습니다 . -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- - 현재 만들어야 하는 버거의 갯수 : 5 MacChicken 버거를 만드는 중 입니다 . 현재 만들어야 하는 버거의 갯수 : 4 BicMac 버거를 만드는 중 입니다 . 현재 만들어야 하는 버거의 갯수 : 3 MacSpicy 버거를 만드는 중 입니다 . 현재 만들어야 하는 버거의 갯수 : 2 EggBulgogi 버거를 만드는 중 입니다 . MacChicken 버거가 다 만들어졌습니다 . -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- - 현재 만들어야 하는 버거의 갯수 : 1 MacChicken 버거를 만드는 중 입니다 . BicMac 버거가 다 만들어졌습니다 . -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- - MacSpicy 버거가 다 만들어졌습니다 . -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- - 현재 만들어야 하는 버거의 갯수 : 0 BicMac 버거를 만드는 중 입니다 . EggBulgogi 버거가 다 만들어졌습니다 . -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- - MacChicken 버거가 다 만들어졌습니다 . -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- - BicMac 버거가 다 만들어졌습니다 . -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- - Process finished with exit code 0 교착상태 (dead-lock) ⭐️ 둘 이상의 쓰레드가 lock을 획득하기 위해서 기다리는데, 이 lock을 잡고 있는 쓰레드도 똑같이 다른 lock을 기다리며 서로 블록 상태에 놓이는 것을 말한다. 즉 다수의 쓰레드가 같은 lock을 동시에, 다른 명령에 의해 획득하려는 시도가 발생할 경우에 생긴다. public class TestThread { public static Object Lock1 = new Object ( ) ; public static Object Lock2 = new Object ( ) ; public static void main ( String args [ ] ) { ThreadDemo1 T1 = new ThreadDemo1 ( ) ; ThreadDemo2 T2 = new ThreadDemo2 ( ) ; T1 . start ( ) ; T2 . start ( ) ; } private static class ThreadDemo1 extends Thread { public void run ( ) { synchronized ( Lock1 ) { System . out . println ( "Thread 1: Holding lock 1..." ) ; try { Thread . sleep ( 10 ) ; } catch ( InterruptedException e ) { } System . out . println ( "Thread 1: Waiting for lock 2..." ) ; synchronized ( Lock2 ) { System . out . println ( "Thread 1: Holding lock 1 & 2..." ) ; } } } } private static class ThreadDemo2 extends Thread { public void run ( ) { synchronized ( Lock2 ) { System . out . println ( "Thread 2: Holding lock 2..." ) ; try { Thread . sleep ( 10 ) ; } catch ( InterruptedException e ) { } System . out . println ( "Thread 2: Waiting for lock 1..." ) ; synchronized ( Lock1 ) { System . out . println ( "Thread 2: Holding lock 1 & 2..." ) ; } } } } } Thread 1 : Holding lock 1. . . Thread 2 : Holding lock 2. . . Thread 1 : Waiting for lock 2. . . Thread 2 : Waiting for lock 1. . . 참고자료 🧾

[C++] 멀티스레딩 프로그래밍 (1)

References

Contents

멀티스레드 프로그래밍 개념

Thread

Atomic Operations Library

이번 포스팅에서는 C++의 멀티스레딩 프로그래밍에 대해서 알아보려고 합니다.

[C++] thread

[C++] mutex

[C++] 생산자(Producer) / 소비자(Consumer) 패턴

[C++] 비동기(Asynchronous) 실행

예전에 위의 포스팅들을 통해서 살펴봤었는데, 이번 포스팅을 통해서 전체적으로 정리해보려고 합니다.

멀티스레딩 프로그래밍(multithreaded programming)은 프로세서 유닛이 여러 개 장착된 컴퓨터 시스템에서 중요한 기법이며, 이를 이용하여 시스템에 있는 여러 프로세서 유닛을 병렬로 사용하는 프로그램을 작성할 수 있습니다.

이처럼 프로세서의 기능을 최대한 활용할 수 있도록 멀티스레드 코드를 정확하게 작성할 줄 알아야 하는데, 멀티스레드 어플리케이션은 플랫폼이나 OS에서 제공하는 API에 상당히 의존합니다. 그래서 멀티스레드 코드를 플랫폼 독립적으로 작성하기는 힘듭니다. C++11부터 제공되는 표준 스레딩 라이브러리를 활용하면 이를 어느정도 해결할 수 있습니다.

멀티스레드 프로그래밍을 플랫폼 독립적으로 할 수 있게 해주는 서드파티 라이브러리(ex, pthreads와 boost::thread)도 있습니다만, 이 라이브러리는 C++에 속하지는 않습니다.

1. 멀티스레드 프로그래밍 개념

멀티스레드 프로그래밍을 사용하면 여러 연산을 병렬로 처리할 수 있습니다. 그래서 현재는 거의 모든 시스템에 장착된 멀티 프로세서를 최대한 활용할 수 있습니다.

C++98/03 버전은 멀티스레드 프로그래밍을 지원하지 않아서 서드파티 라이브러리나 타겟 시스템의 OS에서 제공하는 멀티스레드 API를 활용하는 수밖에 없었습니다. C++11부터 표준 멀티 스레드 라이브러리가 추가되면서 크로스 플랫폼 멀티스레드 프로그램을 작성하기 한결 쉬워졌습니다. 현재 C++ 표준은 GPU를 제외한 CPU만을 대상으로 API를 정의하고 있지만, 향후 GPU도 지원하도록 개선되지 않을까 가능성을 열어두고 있습니다.

멀티스레드 프로그래밍이 필요한 이유는 크게 두 가지가 있습니다. 첫째, 주어진 연산 작업을 작은 문제들로 나눠서 각각을 멀티프로세서 시스템에서 병렬로 실행하면 전반적인 성능을 크게 높일 수 있습니다.

둘째, 연산을 다른 관점에서 모듈화할 수 있습니다. 예를 들어 연산을 UI 스레드에 종속적이지 않은 독립 스레드로 분리해서 구현하면 처리 시간이 긴 연산을 백그라운드로 실행시키는 방식으로 UI의 응답 속도를 높일 수 있습니다.

다음 그림은 병렬 처리가 절대적으로 유리한 상황을 보여주고 있습니다.

싱글 코어 프로세서에서는 모든 부분을 순차적으로 실행해야 하고, 듀얼 코어 프로세서에서는 두 부분씩 동시에 실행할 수 있고, 쿼드 코어 프로세서에서는 각 부분을 동시에 실행할 수 있습니다. 이처럼 성능이 코어 수에 정비례합니다.

물론 항상 이렇게 독립 작업으로 나눠서 병렬화할 수 있는 것은 아닙니다. 그래도 최소한 일부분만이라도 병렬화할 수 있다면 조금이라도 성능을 높일 수 있습니다. 멀티스레드 프로그래밍을 하는 데 어려운 부분은 병렬 알고리즘을 고안하는 것입니다. 처리할 작업의 성격에 따라 구현 방식이 크게 달라지기 때문입니다. 또한 경쟁 상태(race condition), 교착 상태(deadlocks), 테어링(tearing), 거짓 공유(false-sharing) 등과 같은 문제가 발생하지 않게 만드는 것도 쉽지 않습니다.

이어지는 내용에서 이러한 문제점에 대해서 간단하게 살펴보겠습니다. 이런 문제는 주로 아토믹과 명시적인 동기화 메커니즘으로 해결하는데, 문제점에 대한 해결 방법도 뒤에서 살펴보도록 하겠습니다.

1.1 Race Conditions

여러 스레드가 공유 리소스를 동시에 접근할 때 경쟁 상태(race condition)가 발생할 수 있습니다. 그중에서도 공유 메모리에 대한 경쟁 상태를 흔히 데이터 경쟁(data races)이라고 부릅니다. 데이터 경쟁은 여러 스레드가 공유 메모리에 동시에 접근할 수 있는 상태에서 최소 하나의 스레드가 그 메모리에 데이터를 쓸 때 발생합니다.

예를 들어 공유 변수가 하나 있는데 어떤 스레드는 이 값을 증가시키고, 또 어떤 스레드는 이 값을 감소시키는 경우를 생각해봅시다. 값을 증가시키거나 감소하려면 현재 값을 메모리에 읽어서 증가나 감소 연산을 수행해야 합니다.

PDP-11이나 VAX와 같은 예전 아키텍처는 아토믹(atomic)하게 실행되는 INC와 같은 인스트럭션을 제공했습니다. 하지만 최신 x86 프로세서에서 제공하는 INC는 더 이상 아토믹하지 않습니다. 다시 말해 INC를 처리하는 도중에 다른 인스트럭션이 실행될 수 있기 때문에 결과가 얼마든지 달라질 수 있습니다.

다음 표는 초기값이 1일 때 감소 연산이 실행되기 전에 증가 연산을 마치는 경우를 보여줍니다.

위의 경우 메모리에 기록되는 최종 결과는 1입니다.

이와 반대로 다음 표와 같이 증가 연산을 수행하는 스레드가 시작하기 전에 감소연산을 수행하는 스레드가 작업을 모두 마쳐서 최종 결과는 1이 됩니다.

하지만 두 작업이 다음 표와 같이 서로 엇갈리게 되면 결과가 달라집니다.

이렇게 되면 최종 결과는 0이 됩니다. 다시 말해 증가 연산의 효과가 사라지는 것입니다. 이러한 현상을 데이터 경쟁이라고 부릅니다.

1.2 Tearing

테어링(tearing)이란 데이터 경쟁의 특수한 경우로서, 크게 torn read와 torn write의 두 가지가 있습니다. 어떤 스레드가 메모리에 데이터의 일부만 쓰고 나머지 부분을 미처 쓰지 못한 상태에서 다른 스레드가 이 데이터를 읽으면 두 스레드가 보는 값이 달라집니다. 이를 torn read라고 합니다. 또한 두 스레드가 이 데이터에 동시에 쓸 때 한 스레드는 그 데이터의 한쪽 부분을 쓰고, 다른 스레드 는그 데이터의 다른 부분을 썼다면 각자 수행한 결과가 달라지는데, 이를 torn write라고 합니다.

1.3 Deadlocks

경쟁 상태를 막기 위해 상호 배재(mutual exlusion)와 같은 동기화 기법을 적용하다 보면 멀티스레드 프로그래밍에서 흔히 발생하는 또 다른 문제인 데드락(deadlocks, 교착상태)에 부딪히기 쉽습니다. 데드락이란 여러 스레드가 서로 상대방 작업이 끝날 때까지 동시에 기다리는 상태를 말합니다.

예를 들어 두 스레드가 공유 리소스를 서로 접근하려면 먼저 그 리소스에 대한 접근 권한 요청부터 해야합니다. 현재 둘 중 한 스레드가 그 리소스에 대한 접근 권한을 확보한 상태로 계속 머물러 있으면 그 리소스에 대한 접근 권한을 요청하는 다른 스레드도 무한히 기다려야 합니다.

이때 공유 리소스에 대한 접근 권한을 얻는 방법에는 뮤텍스라는 것이 있습니다 (뒤에서 뮤텍스에 관해서 설명하도록 하겠습니다). 예를 들어 스레드가 두 개 있고 리소스도 두 개 있을 때 이를 A와 B라는 뮤텍스 객체로 보호하고 있다고 해봅시다. 이때 두 스레드가 각 리소스에 대한 접근 권한을 얻을 수 있지만, 그 순서는 다음 표와 같이 서로 다른 경우들에 대해 살펴보도록 하겠습니다.

이 스레드가 실행되면 다음의 순서로 진행될 수 있습니다.

Thread 1: A 확보

Thread 2: B 확보

Thread 1: B 확보 (Thread 2가 B를 확보하고 있기 때문에 대기)

Thread 2: A 확보 (Thread 1이 A를 확보하고 있기 때문에 대기)

이렇게 되면 두 스레드 모두 상대방을 무한정 기다리는 데드락이 발생합니다. 이러한 드데락 상황을 그림으로 표현하면 아래와 같습니다.

스레드 1은 A 리소스에 대한 접근 권한을 확보한 상태에서 B 리소스의 접근 권한을 얻을 때까지 기다리고, 스레드 2는 B 리소스의 접근 권한을 확보한 상태에서 A 리소스의 접근 권한을 얻을 때까지 기다립니다. 위 그림을 보면 데드락 상황이 순환 관계를 이루고 있으며, 결국 두 스레드는 서로를 무한정 기다리게 됩니다.

이러한 데드락이 발생하지 않게 하려면 모든 스레드가 일정한 순서로 리소스를 획득해야 합니다. 또한 데드락이 발생해도 빠져나올 수 있는 메커니즘을 함께 구현하면 좋습니다. 한 가지 방법은 리소스 접근 권한을 요청하는 작업에 시간제한을 걸어두는 것입니다. 그래서 주어진 시간 안에 리소스를 확보할 수 없으면 더 이상 기다리지 않고 현재 확보한 권한을 해제합니다. 이렇게 하면 다른 스레드가 리소스에 접근할 기회를 줄 수 있습니다. 물론 이 기법만으로 문제를 해결할 수 있는지는 주어진 데드락 상황에 따라 다릅니다.

방금 언급한 우회 기법으로 해결하는 것보다는 데드락 상황 자체를 아예 발생하지 않도록 하는 것이 좋은데, 여러 뮤텍스 객체로 보호받고 있는 리소스 집합에 대해 접근 권한을 얻을 때는 리소스마다 접근 권한을 개별적으로 요청하지 않고 std::lock()이나 std::try_lock()과 같은 함수를 사용하는 것이 좋습니다. 이 함수들은 여러 리소스에 대한 권한을 한 번에 확보하거나 요청합니다.

1.4 False-Sharing

대부분 캐시(cache)는 캐시 라인(cache line) 단위로 처리됩니다. 최신 CPU는 흔히 64바이트 캐시 라인으로 구성됩니다. 캐시 라인에 데이터를 쓰려면 반드시 그 라인 전체에 락을 걸어야 합니다. 멀티 스레드 코드를 실행할 때 데이터 구조를 잘 만들지 않으면 캐시 라인에 락을 거는 과정에서 성능이 크게 떨어질 수 있습니다.

예를 들어, 두 스레드가 두 가지 데이터 영역을 사용하는데, 데이터가 같은 캐시 라인에 걸쳐있는 경우를 생각해봅시다. 이때 한 스레드가 데이터를 업데이트하면 캐시 라인 전체에 락을 걸어버리기 때문에 다른 스레드는 기다려야 합니다. 아래 그림은 두 스레드가 다른 메모리 블럭을 쓰지만 같은 캐시 라인을 공유하는 모습을 보여줍니다.

캐시 라인에 걸쳐 있지 않도록 데이터 구조가 저장될 메모리 영역을 명시적으로 정렬하면 여러 스레드가 접근할 때 대기하지 않게 만들 수 있습니다. 이러한 코드를 이식하기 좋게 작성할 수 있도록 C++17부터 헤더 파일에 hardware_destructive_interference_size란 상수가 추가되었습니다. 이 상수는 동시에 접근하는 두 객체가 캐시 라인을 공유하지 않도록 최소한의 오프셋을 제시해줍니다. 이 값과 alignas 키워드를 사용하여 데이터를 적절히 정렬할 수 있습니다.

2. Threads

헤더 파일에 정의된 C++ 스레드 라이브러리를 사용하면 스레드를 매우 간편하게 생성할 수 있습니다. 이때 새로 만든 스레드가 할 일을 지정하는 방식은 다양합니다. 전역 함수로 표현하거나, 함수 객체의 operator()로 표현하거나 람다 표현식으로 지정하거나 특정 클래스의 인스턴스에 있는 멤버 함수로 지정할 수도 있습니다. 각 방법을 하나씩 살펴보겠습니다.

2.1 Threads with Function Pointer

윈도우 시스템의 CreateThread(), _beginthread()와 같은 함수나 pthreads 라이브러리의 pthread_create()와 같은 스레드 함수는 매개변수를 하나만 받습니다. 반면 C++ 표준에서 제공하는 std::thread 클래스에서 사용하는 함수는 매개변수를 원하는 개수만큼 받을 수 있습니다.

예를 들어 다음과 같이 정수 두 개를 인수로 받는 counter() 함수를 살펴보겠습니다. 첫 번째 인수는 ID를 표현하고, 두 번째 인수는 이 함수가 루프를 도는 횟수를 표현합니다. 이 함수는 인수로 지정한 횟수만큼 표준 출력에 메세지를 보내는 문장을 반복합니다.

void counter(int id, int numIterations) { for (int i = 0; i < numIterations; i++) { std::cout << "Counter: " << id << " has value " << i << std::endl; } } std::thread를 이용하면 이 함수를 여러 스레드로 실행하게 만들 수 있습니다. 예를 들어 인수로 1과 6을 지정해서 counter()를 수행하는 스레드인 t1을 다음과 같이 생성할 수 있습니다. std::thread t1(counter, 1, 6); thread 클래스 생성자는 가변인수 템플릿이기 때문에 인수 개수를 원하는 만큼 지정할 수 있습니다. 첫 번째 인수는 새로 만들 스레드가 실행할 함수의 이름입니다. 그 뒤에 나오는 인수는 스레드가 구동되면서 실행할 함수에 전달할 인수들입니다. 현재 시스템에서 thread 객체가 active 스레드로 표현될 때 이 thread 객체를 joinable하다고 표현합니다. 이런 스레드는 실행을 마치고 나서도, thread 객체가 joinable 상태를 유지합니다. 디폴트로 생성된 thread 객체는 unjoinable합니다. joinable한 thread 객체를 제거하려면, 먼저 그 객체의 join()이나 detach() 메소드를 호출해야 합니다. join()을 호출하는 것은 blocking call이며, join()을 호출한 스레드는 해당 thread 객체가 작업을 끝날 때까지 기다립니다. detach()를 호출하면 thread 객체를 OS 내부의 스레드와 분리합니다. 따라서 OS 스레드는 이 객체와 독립적으로 실행됩니다. 두 메소드는 모두 스레드를 unjoinable한 상태로 전환시킵니다. joinable한 상태의 thread 객체를 제거하면 그 객체의 소멸자는 std::terminate()를 호출해서 모든 스레드뿐만 아니라 어플리케이션도 종료시킵니다. 다음 코드는 counter() 함수를 실행하는 스레드를 2개 생성합니다. main()에서 스레드를 생성하고 나서 곧바로 두 스레드에 대해 join()을 호출합니다. int main() { std::thread t1(counter, 1, 6); std::thread t2(counter, 2, 4); t1.join(); t2.join(); } 이 코드를 실행해보면, 아래와 같이 조금 이상하게 출력됩니다. 코드를 실행하는 시스템마다 결과가 달라질 수 있고, 같은 시스템에서도 실행할 때마다 결과가 달라질 수 있습니다. 두 스레드가 counter() 함수를 동시에 실행하므로 시스템에 장착된 코어 수와 OS의 스레드 스케줄링 방식에 따라 출력 형태가 달라집니다. 기본적으로 cout에 접근하는 작업은 thread-safe하기 때문에 여러 스레드 사이에서 데이터 경쟁이 발생하지 않습니다 (출력이나 입력 연산 직전에 cout.sync_with_stdio(false)를 호출하지 않았을 경우). 하지만 데이터 경쟁이 발생하지 않더라도 스레드마다 출력한 결과는 여전히 겹쳐질 수 있습니다. 동기화 기법을 적용하면 뒤섞이지 않게 만들 수 있는데, 이에 대해서는 뒤에서 살펴보겠습니다. 2.2 Thread with Function Object 이번에는 함수 객체로 스레드를 실행시키는 방법을 알아보도록 하겠습니다. 방금 소개한 방법대로 함수 포인터로 스레드를 만들면 함수에 인수를 전달하는 방식으로만 스레드에 정보를 전달할 수 있습니다. 반면 함수 객체로 만들면 그 함수 객체의 클래스에 멤버 변수를 추가해서 원하는 방식으로 초기화해서 사용할 수 있습니다. 다음 예제 코드는 먼저 Counter라는 클래스를 정의합니다. 이 클래스는 ID와 반복 횟수를 표현하는 멤버 변수를 가지고 있습니다. 두 변수 모두 생성자로 초기화됩니다. Counter 클래스를 함수 객체로 만들기 위해 operator()를 구현하였습니다. class Counter { public: Counter(int id, int numIterations) : m_id{ id }, m_numIterations{ numIterations } {} void operator()() const { for (int i = 0; i < m_numIterations; i++) { std::cout << "counter " << m_id << " has value " << i << std::endl; } } private: int m_id; int m_numIterations; }; 다음 코드는 함수 객체를 만든 스레드를 초기화하는 2가지 방법을 보여줍니다. 첫 번째 방법은 유니폼 초기화(uniform initialization) 문법을 사용합니다. Counter 생성자에 인수를 지정해서 인스턴스를 생성하면 그 값이 중괄호로 묶인 thread 생성자 인수로 전달됩니다. 두 번째 방법은 Counter 인스턴스를 일반 변수처럼 이름있는 인스턴스로 정의하고, 이를 thread 클래스의 생성자로 전달합니다. int main() { // Using uniform initialization syntax std::thread t1{ Counter{1, 20} }; // Using named variable. Counter c{ 2, 12 }; std::thread t2{ c }; // Wait for threads to finish. t1.join(); t2.join(); } 함수 객체는 항상 스레드의 내부 저장소에 복사됩니다. 함수 객체의 인스턴스를 복사하지 않고 그 인스턴스에 대해 operator()를 호출하려면 헤더에 정의된 std::ref()나 cref()를 사용해서 인스턴스를 레퍼런스로 전달해야 합니다.

예를 들면, 다음과 같습니다.

Counter c{ 2, 12 }; std::thread t2{ std::ref(c) };​

2.3 Thread with Lambda

람다 표현식은 표현 C++ 스레드 라이브러리에 적합합니다. 예를 들어 람다 표현식으로 정의한 작업을 실행하는 스레드를 생성하는 코드를 다음과 같이 작성할 수 있습니다.

int main() { int id{ 1 }; int numIterations{ 5 }; std::thread t1{ [id, numIterations] { for (int i = 0; i < numIterations; ++i) { std::cout << "Counter " << id << " has value " << i << std::endl; } } }; t1.join(); } 2.4 Thread Local Storage 스레드에서 실행할 내용을 클래스의 멤버 함수로 지정할 수도 있습니다. 다음 코드는 기본 Request 클래스에 process() 메소드를 정의하고 있습니다. 그리고 main() 함수에서 Request 클래스의 인스턴스를 생성하고, Request 인스턴스인 req의 process() 메소드를 실행하는 스레드를 생성합니다. class Request { public: Request(int id) : m_id{ id } {} void process() { std::cout << "Processing request: " << m_id << std::endl; } private: int m_id; }; int main() { Request req{ 100 }; std::thread t{ &Request::process, &req }; t.join(); } 이렇게 하면 특정한 객체에 있는 메소드를 스레드로 분리해서 실행할 수 있습니다. 똑같은 객체를 여러 스레드가 접근할 때 데이터 경쟁이 발생하지 않도록 스레드에 안전하게 작성해야 합니다. 스레드에 안전하게 구현하는 방법 중 하나는 이어지는 내용에서 설명할 뮤텍스라는 동기화 기법을 활용하는 것입니다. 2.5 Thread Local Storage C++ 표준은 스레드 로컬 저장소(thread local storage)란 개념을 제공합니다. 스레드 로컬로 지정하고자 하는 변수에 thread_local이란 키워드를 지정하면, 각 스레드마다 이 변수를 복사해서 스레드가 없어질 때까지 유지합니다. 이 변수는 각 스레드에서 한 번만 초기화됩니다. 예를 들어 다음 코드에는 두 개의 전역 변수가 정의되어 있는데, 모든 스레드가 하나의 k 복사본을 공유하며, 각 스레드는 자신의 고유한 n의 복사본을 가집니다. int k; thread_local int n; 다음 코드는 위 설명에 대한 내용을 보여주고 있습니다. threadFunction()은 k와 n의 현재 값을 콘솔에 출력하고 둘 다 1씩 증가시킵니다. main() 함수는 첫 번째 스레드를 실행하고, 이 스레드가 종료될 때까지 기다렸다가 두 번째 스레드를 실행시킵니다. int k; thread_local int n; void threadFunction(int id) { std::cout << "Thread " << id << ": k=" << k << ", n=" << n << std::endl; ++n; ++k; } int main() { std::thread t1{ threadFunction, 1 }; t1.join(); std::thread t2{ threadFunction, 2 }; t2.join(); } 위 코드를 실행한 결과는 다음과 같습니다. 여기서 모든 스레드가 하나의 k 인스턴스를 공유하고, 각 스레드들은 각자의 n을 가지고 있는 것을 확인할 수 있습니다. 만약 thread_local 변수를 함수 스코프 내에서 선언하면 모든 스레드가 복사본을 따로 갖고 있고, 함수를 아무리 많이 호출하더라도 스레드마다 단 한 번만 초기화된다는 점을 제외하면 static으로 선언할 때와 동일하게 동작합니다. 2.6 Canceling Threads C++ 표준은 현재 실행 중인 스레드를 다른 스레드에서 중단시키는 메커니즘을 포함하지는 않습니다. 이렇게 다른 스레드를 종료시키기 위한 가장 좋은 방법은 여러 스레드가 공통으로 따르는 통신 메커니즘을 따르는 것입니다. 가장 간단한 방법은 공유 변수를 활용하는 것인데, 값을 전달받은 스레드는 이 값을 주기적을 확인하면서 중단 여부를 결정합니다. 나머지 스레드는 이러한 공유 변수를 이용해 이 스레드를 간접적으로 종료시킬 수 있습니다. 하지만 이때 조심해야 할 점이 있는데, 여러 스레드가 공유 변수에 접근하기 때문에 하나 이상의 스레드가 그 변수에 값을 쓸 수 있습니다. 따라서 이 변수를 아토믹(atomic)이나 조건 변수(condition variables)로 만드는 것이 좋습니다. 이에 대한 내용은 아래에서 다루도록 하겠습니다. 또 다른 방법으로 C++20부터 제공되는 jthread 클래스를 사용하는 것이 있는데, 아직 자세히 살펴보진 못해서 이번 포스팅에서는 다루지 않겠습니다 ! 2.7 Retrieving Results from Threads 지금까지 살펴본 예제들에서 볼 수 있듯이 스레드를 새로 만드는 것은 어렵지 않습니다. 하지만 정작 중요한 부분은 스레드로 처리한 결과입니다. 예를 들어 스레드로 수학 연산을 수행하면 모든 스레드가 종료한 뒤에 나오는 최종 결과를 구해야 합니다. 한 가지 방법은 결과를 담은 변수에 대한 포인터나 레퍼런스를 스레드로 전달해서 스레드마다 결과를 저장하도록 만드는 것입니다. 또 다른 방법은 함수 객체의 클래스 멤버 변수에 처리 결과를 저장했다가 나중에 스레드가 종료할 때 그 값을 가져오는 것입니다. 이렇게 하려면 반드시 std::ref()를 이용해서 함수 객체의 레퍼런스를 thread 생성자에 전달해야 합니다. 그런데, 이보다 더 쉬운 방법이 있긴 합니다. 바로 future를 활용하는 것인데, 이를 사용하면 스레드 안에서 발생한 에러를 처리하기도 쉽습니다. 이에 대한 내용은 포스팅 마지막 부분에서 자세히 다루어보도록 하겠습니다. 2.8 Copying and Rethrowing Exceptions 스레드가 하나만 있을 때는 C++ 익셉션 메커니즘 관련 문제가 발생할 일이 없습니다. 그런데 스레드에서 던진 익셉션은 그 스레드 안에서 처리해야 합니다. 던진 익셉션을 스레드 안에서 잡지 못하면 C++ 런타임은 std::terminate()를 호출해서 어플리케이션 전체를 종료시킵니다. 한 스레드에서 던진 익셉션을 다른 스레드에서 잡을 수는 없습니다. 그래서 멀티스레드 환경에서 익셉션을 처리하는 과정에 여러 가지 문제가 발생합니다. 표준 스레드 라이브러리를 사용하지 않고도 스레드 사이에 발생한 익셉션을 처리할 수는 있지만 굉장히 힘듭니다. 이를 위해 표준 스레드 라이브러리는 다음과 같은 익셉션 관련 함수를 제공합니다. 이 함수는 std::exception뿐만 아니라 int, string, 커스텀 익셉션 등에도 적용됩니다. exception_ptr current_exception() noexcept; 이 함수는 catch 블록에서 호출하며, 현재 처리할 익셉션을 가리키는 exception_ptr 객체나 그 복사본을 리턴합니다. 현재 처리하는 익셉션이 없으면 null exception_ptr 객체를 리턴합니다. 이때 참조하는 익셉션 객체는 exception_ptr 타입의 객체가 존재하는 한 유효합니다. exception_ptr의 타입은 NullablePointer이기 때문에 간단히 if문을 작성해서 테스트하기가 쉽습니다. 이에 관련한 예제는 아래에서 살펴보겠습니다. 이 함수는 catch 블록에서 호출하며, 현재 처리할 익셉션을 가리키는 exception_ptr 객체나 그 복사본을 리턴합니다. 현재 처리하는 익셉션이 없으면 null exception_ptr 객체를 리턴합니다. 이때 참조하는 익셉션 객체는 exception_ptr 타입의 객체가 존재하는 한 유효합니다. exception_ptr의 타입은 NullablePointer이기 때문에 간단히 if문을 작성해서 테스트하기가 쉽습니다. 이에 관련한 예제는 아래에서 살펴보겠습니다. [[noreturn]] void rethrow_exception(exception_ptr p); 이 함수는 exception_ptr 매개변수가 참조하는 익셉션을 다시 던집니다. 참조한 익셉션을 반드시 그 익셉션이 처음 발생한 스레드 안에서만 다시 던져야 한다는 법은 없습니다. 그래서 여러 스레드에서 발생한 익셉션을 처리하는 용도로 딱 맞습니다. [[noreturn]] 속성은 이 함수가 정상적으로 리턴하지 않는다는 것을 선언합니다. 이 함수는 exception_ptr 매개변수가 참조하는 익셉션을 다시 던집니다. 참조한 익셉션을 반드시 그 익셉션이 처음 발생한 스레드 안에서만 다시 던져야 한다는 법은 없습니다. 그래서 여러 스레드에서 발생한 익셉션을 처리하는 용도로 딱 맞습니다. [[noreturn]] 속성은 이 함수가 정상적으로 리턴하지 않는다는 것을 선언합니다. template exception_ptr make_exception_ptr(E e) noexcept;

이 함수는 주어진 익셉션 객체의 복사본을 참조하는 exception_ptr 객체를 생성합니다. 실질적으로 다음 코드의 축약 표현입니다.

try { throw e; } catch (…) { return current_exception(); }

이러한 함수로 스레드에서 발생한 익셉션을 처리하는 방법을 살펴보겠습니다.

다음 코드는 일정한 작업을 수행한 뒤 익셉션을 던지는 함수를 정의합니다. 이 함수는 별도 스레드로 실행합니다.

void doSomeWork() { for (int i = 0; i < 5; i++) { std::cout << i << std::endl; } std::cout << "Thread throwing a runtime_error exception... "; throw std::runtime_error{ "Exception from thread" }; } 다음 threadFunc() 함수는 doSomeWork()가 던진 익셉션을 모두 받도록 try/catch 블록으로 묶습니다. threadFunc()은 exception_ptr& 타입 인수 하나만 받습니다. 익셉션을 잡았다면 current_exception() 함수를 이용하여 처리할 익셉션에 대한 레퍼런스를 받아서 exception_ptr 매개변수에 대입합니다. 그런 다음 스레드는 정상적으로 종료합니다. void threadFunc(std::exception_ptr& err) { try { doSomeWork(); } catch (...) { std::cout << "Thread caught exception, returning exception... "; err = std::current_exception(); } } 아래의 doWorkInThread() 함수는 메인 스레드에서 호출됩니다. 이 함수는 스레드를 생성해서 그 안에 담긴 threadFunc() 의 실행을 시작하는 역할을 담당합니다. threadFunc()의 인수로 exception_ptr 타입 객체에 대한 레퍼런스를 지정합니다. 일단 스레드가 생성되면 doWorkInThread() 함수는 join() 메소드를 이용하여 이 스레드가 종료될 때까지 기다리고, 그 후 에러 객체가 발생하는지 검사합니다. exception_ptr은 NullablePointer 타입이기 때문에 if문으로 간단하게 검사할 수 있습니다. 이 값이 널이 아니라면 현재 스레드에 그 익셉션을 다시 던집니다. 이 예제에서는 메인 스레드가 현재 스레드입니다. 이 익셉션을 메인 스레드에서 다시 던지기 때문에 한 스레드에서 다른 스레드로 전달됩니다. void doWorkInThread() { std::exception_ptr error; // Launch thread std::thread t{ threadFunc, std::ref(error) }; // Wait for thread to finish t.join(); if (error) { std::cout << "Main thread received exception, rethrowing it... "; std::rethrow_exception(error); } else { std::cout << "Main thread did not receive any exception. "; } } 여기서 구현한 main() 함수는 간단합니다. doWorkInThread()를 호출하고, doWorkInThread()에서 생성한 스레드가 던진 익셉션을 받도록 try/catch 블록을 작성합니다. int main() { try { doWorkInThread(); } catch (const std::exception& e) { std::cout << "Main function caught: '" << e.what() << "'" << std::endl; } } 위 코드의 실행 결과는 다음과 같습니다. 여기서는 예제를 간결하게 구성하기 위해 main 스레드에서 join()을 호출해서 메인 스레드에 블록시키고 스레드가 모두 마칠 때까지 기다립니다. 물론 실전에서는 이렇게 메인 스레드를 블록시키면 안됩니다. 예를 들어 GUI 어플리케이션에서 메인 스레드를 블록시키면 UI가 반응하지 않게 됩니다. 이럴 때는 스레드끼리 메세지로 통신하는 기법을 사용하는 것이 좋습니다. 예를 들어 앞에서 본 threadFunc() 함수는 current_exception() 결과의 복사본을 인자로 하여 UI 스레드로 메세지를 보낼 수 있습니다. 하지만 앞서 이야기했듯이 이렇게 하더라도 생성된 스레드에 대해 join()이나 detach()를 호출해야 합니다. 3. Atomic Operations Library 아토믹 타입(atomic type)을 사용하면 동기화 기법을 적용하지 않고 읽기와 쓰기를 동시에 처리하는 아토믹 액세스(atomic access)가 가능합니다. 아토믹 연산을 사용하지 않고 변수의 값을 증가시키면 스레드에 안전하지 않습니다. 컴파일러는 먼저 메모리에서 이 값을 읽고, 레지스터로 불러와서 값을 증가시킨 다음, 그 결과를 메모리에 다시 저장합니다. 그런데 이 과정에서 같은 메모리 영역을 다른 스레드가 건드리면 데이터 경쟁이 발생합니다. 예를 들어 다음 코드는 스레드에 안전하지 않게 작성이 되어 데이터 경쟁이 발생하는 상황을 보여줍니다. int counter{ 0 }; // global variable ... ++counter; // Executed in multiple threads 이 변수에 std::atomic 타입을 적용하면 다음에 설명할 뮤텍스 객체와 같은 동기화 기법을 따로 사용하지 않고도 스레드에 안전하게 만들 수 있습니다. 위의 코드를 atomic 타입을 사용하도록 고치면 다음과 같습니다. std::atomic counter{ 0 }; // global variable … ++counter; // Excuted in multiple threads

아토믹 타입을 사용하려면 헤더 파일을 인클루드해야 합니다. C++ 표준은 언어에서 제공하는 모든 기본 타입마다 이름이 지정된 정수형 아토믹 타입(named integral atomic type)을 정의하고 있습니다.

다음 표는 몇 가지 아토믹 타입을 보여줍니다.

아토믹 타입을 사용할 때는 동기화 메커니즘을 명시적으로 사용하지 않아도 됩니다. 하지만 특정 타입에 대해 아토믹 연산으로 처리할 때는 뮤텍스와 같은 동기화 메커니즘을 내부적으로 사용하기도 합니다. 예를 들어 연산을 아토믹으로 처리하는 인스트럭션을 타겟 하드웨어에서 제공하지 않을 수 있습니다. 이런 경우에는 아토믹 타입에 대해 is_lock_free() 메소드를 호출해서 lock-free 인지, 즉, 명시적으로 동기화 메커니즘을 사용하지 않고도 수행할 수 있는지 확인해야 합니다.

std::atomic 클래스 템플릿은 정수 타입뿐만 아니라 다른 모든 종류의 타입에 대해서도 적용할 수 있습니다. 예를 들어 atomic이나 atomic과 같이 쓸 수 있습니다. 단, MyType을 쉽게 복사할 수 있어야 합니다. 지정한 타입의 크기에 따라 내부적으로 동기화 메커니즘을 사용해야 할 수도 있습니다.

다음 예제 코드를 보면 Foo와 Bar는 쉽게 복사할 수 있습니다. 다시 말해 이 두 클래스에 대해 std::is_trivially_copyable_v가 true 입니다. 하지만 atomic는 lock-free가 아니고, atomic는 lock-free 입니다.

class Foo { private: int m_array[123]; }; class Bar { private: int m_int; }; int main() { std::atomic f; // Outputs: 1 0 std::cout << std::is_trivially_copyable_v << " " << f.is_lock_free() << std::endl; std::atomic b; // Outputs: 1 1 std::cout << std::is_trivially_copyable_v << " " << b.is_lock_free() << std::endl; } 일정한 데이터를 여러 스레드가 동시에 접근할 때 아토믹을 사용하면 메모리 순서, 컴파일러 최적화 등과 같은 문제를 방지할 수 있습니다. 기본적으로 아토믹이나 동기화 메커니즘을 사용하지 않고서 동일한 데이터를 여러 스레드가 동시에 읽고 쓰는 것은 위험합니다. 3.1 Atomic Operations C++ 표준에서는 여러 가지 아토믹 연산을 정의하고 있습니다. 그중 몇 가지만 살펴보도록 하겠습니다. 아토믹 연산에 대한 첫 번째 예제는 다음과 같습니다. bool std::atomic::compare_exchange_strong(T& expected, T desired);

이 연산을 아토믹하게 수행하도록 구현하면 다음과 같습니다. 여기서는 pseudo 코드로 표현했습니다.

if (*this == expected) { *this = desired; return true; } else { expected = *this; return false; }

얼핏보면 조금 이상하지만, 데이터 구조를 락을 사용하지 않고 동시에 접근하게 만드는 데 핵심적인 연산입니다. 이렇게 lock-free 동시성 데이터 구조(concurrent data structure)를 이용하면 이 데이터 구조에 대해 연산을 수행할 때 동기화 메커니즘을 따로 사용하지 않아도 됩니다. 하지만 이렇게 데이터 구조를 표현하는 기법은 고급 주제에 해당하며 여기서 다루지는 않겠습니다.

CUDA Instructions (2) – Instruction 최적화

혹시 atomic 연산에 대해 조금 더 이해하고 싶으시다면, 위 포스팅의 마지막 부분에 아토믹 연산에 대한 내용을 다루고 있으니 참조하시면 도움이 되실 것 같습니다.. !

두 번째 예제는 정수 아토믹 타입에 적용되는 atomic::fetch_add()를 사용하는 것입니다. fetch_add()는 주어진 아토믹 타입의 현재 값을 가져와서 지정한 값만큼 증가시킨 다음, 증가시키기 전의 원래 값을 리턴합니다. 예를 들면 다음과 같습니다.

int main() { std::atomic value{ 10 }; std::cout << "Value = " << value << std::endl; int fetched{ value.fetch_add(4) }; std::cout << "Fetched = " << fetched << std::endl; std::cout << "Value = " << value << std::endl; } fetched와 value 변수의 값을 건드리는 다른 스레드가 없다면 결과는 다음과 같습니다. 정수형 아토믹 타입은 fetch_add(), fetch_sub(), fetch_and(), fetch_or(), fetch_xor(), ++, --, +=, -=, &=, ^=, |=과 같은 아토믹 연산을 지원합니다. 아토믹 포인터 타입은 fetch_add(), fetch_sub(), ++, --, +=, -=을 지원합니다. C++20 이전에는 부동소수점 타입에 std::atomic을 사용(ex, atomic, atomic)하여 atomic reading/writing을 제공했지만, atomic 산술 연산은 제공하지 않았습니다. C++20에서는 부동소수점 아토믹 타입에 fetch_add()와 fetch_sub()를 지원하도록 추가되었습니다.

대부분의 아토믹 연산은 원하는 메모리 순서를 지정하는 매개변수를 추가로 받습니다. 예를 들면 다음과 같습니다.

T atomic::fetch_add(T value, memory_order = memory_order_seq_cst);

그러면 디폴트로 설정된 memory_order를 다른 값으로 변경할 수 있습니다. C++ 표준은 이를 위해서 memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel, memory_order_seq_cst를 제공하며, 모두 std 네임스페이스 아래에 정의되어 있습니다. 하지만 디폴트값이 아닌 다른 값을 지정할 일은 별로 없습니다. 디폴트보다 나은 성능을 보여주는 메모리 순서가 있지만 자칫 잘못하면 데이터 경쟁이 발생하거나 디버깅하기 힘든 스레드 관련 문제가 발생할 수 있습니다.

3.2 Atomic Smart Pointer (C++20)

C++20부터는 헤더를 통해 atomic>을 지원합니다. 이는 이전 버전의 C++ 표준에서는 허용되지 않았는데, 그 이유는 shared_ptr이 복사될 수 없기 때문(not trivially copyable)입니다. shared_ptr의 reference count를 저장하는 control block은 항상 thread-safe하며, 객체를 가리키는 포인터가 정확히 단 한 번만 삭제되는 것을 보장합니다. 그러나 shared_ptr의 다른 것들은 thread-safe하지 않습니다. 만약 reset()과 같은 비상수(non-const) 메소드가 shared_ptr 인스턴스에서 호출된다면 여러 스레드에서 동시에 사용되는 동일한 shared_ptr은 데이터 경쟁을 일으킵니다.

반면, 여러 스레드에서 동일한 atomic>를 사용할 때는, 비상수 shared_ptr 메소드를 호출이 thread-safe 합니다.

3.3 Atomic References (C++20)

C++20에서는 std::atomic_ref 도 지원합니다. 기본적으로 std::atomic과 동일하며, 인터페이스도 동일합니다만 이것은 레퍼런스에서 동작합니다. 반면에 atomic은 항상 제공되는 값의 복사본을 생성합니다. atomic_ref 인스턴스는 이를 참조하는 객체보다 더 짧은 lifetime을 가져야합니다. atomic_ref는 복사 가능하고, 같은 객체를 가리키는 atomic_ref 인스턴스를 많이 만들수 있습니다. 만약 특정 객체를 참조하는 atomic_ref 인스턴스가 있다면, 그 객체는 atomic_ref 인스턴스 중의 하나를 거치지 않고는 건들일 수 없습니다. atomic_ref 클래스 템플릿은 복사 가능한 타입(trivially copyable type) T에서 사용될 수 있습니다. 또한, 표준 라이브러리는 아래의 내용들을 제공합니다.

fetch_add()와 fetch_sub()를 지원하는 포인터 타입에 대한 partial specialization

fetch_add(), fetch_sub(), fetch_and(), fetch_or() fetch_xor()을 지원하는 정수 타입에 대한 Full specialization

fetch_add(), fetch_sub()를 지원하는 부동소수점에 대한 Full specialization

atomic_ref를 사용하는 방법은 아래에서 살펴보겠습니다.

3.4 Using Atomic Types

이번에는 위에서 atomic 타입을 어떻게 사용할 수 있는지 간단히 알아보겠습니다. 여기서 우리는 increment()라는 함수를 사용할건데, 이 함수는 루프 내에서 정수 레퍼런스 파라미터를 1씩 증가시킵니다. 이 코드에는 std::this_thread::sleep_for()을 사용하는데, 이는 각 루프에서 약간의 딜레이를 주는 역할을 합니다. sleep_for()의 인수는 std::chrono::duration 타입입니다.

using namespace std::literals::chrono_literals; void increment(int& counter) { for (int i = 0; i < 100; i++) { ++counter; std::this_thread::sleep_for(1ms); } } 이제 병렬로 여러 스레드를 실행하는데, 각 스레드는 공유하는 counter 변수에 대해 increment() 함수를 실행합니다. atomic 타입을 사용하지 않거나 어떠한 스레드 동기화 기법을 사용하지 않으면 데이터 경쟁이 발생합니다. 아래 코드는 10개의 스레드를 생성하고, 각 스레드는 join()을 호출함으로써 다른 스레드들이 끝날 때까지 대기합니다. int main() { int counter{ 0 }; std::vector threads; for (int i = 0; i < 10; i++) { threads.push_back(std::thread{ increment, std::ref(counter) }); } for (auto& t : threads) { t.join(); } std::cout << "Result = " << counter << std::endl; } increment() 함수는 counter 파라미터를 100번 증가시키고, 스레드가 10개가 실행되었기 때문에 예상되는 결과는 1000입니다. 하지만 이 함수를 실행시켜보면 1000이 아닌 더 적은 수가 출력되며, 실행할 때마다 다른 값을 가집니다. 위 예제 코드는 데이터 경쟁을 보여주고 있습니다. 이번에는 atomic 타입을 사용하여 위 코드를 수정해보겠습니다. void increment(std::atomic& counter) { for (int i = 0; i < 100; i++) { ++counter; std::this_thread::sleep_for(1ms); } } int main() { std::atomic counter{ 0 }; std::vector threads; for (int i = 0; i < 10; i++) { threads.push_back(std::thread{ increment, std::ref(counter) }); } for (auto& t : threads) { t.join(); } std::cout << "Result = " << counter << std::endl; } 헤더를 include하고 공유되는 counter 변수를 int가 아닌 atomic 타입으로 변경합니다. 이렇게 수정한 버전의 코드를 실행하면 항상 1000이라는 결과를 얻을 수 있습니다.

이렇게 하면 명시적인 동기화 메커니즘을 사용하지 않고도, ++counter 연산은 아토믹으로 수행되기 때문에 어떠한 인터럽트도 받지 않습니다.

C++20에서 지원하는 atomic_ref를 사용해도 데이터 경쟁을 해결할 수 있습니다.

void increment(int& counter) { std::atomic_ref atomicCounter{ counter }; for (int i = 0; i < 100; i++) { ++atomicCounter; std::this_thread::sleep_for(1ms); } } int main() { int counter{ 0 }; std::vector threads; for (int i = 0; i < 10; i++) { threads.push_back(std::thread{ increment, std::ref(counter) }); } for (auto& t : threads) { t.join(); } std::cout << "Result = " << counter << std::endl; } 하지만, 이렇게 아토믹 연산을 사용하면 성능이라는 또 다른 문제가 발생합니다. 이렇게 counter에 대한 연산을 아토믹으로 처리하면 한 번에 한 스레드만 ++counter 연산을 수행하기 때문에 성능의 하락이 발생합니다. 위 예제에서 성능 문제를 해결할 수 있는 아주 간단한 방법은 increment() 함수가 로컬 변수를 사용하여 1씩 증가하는 결과값을 계산하고, 루프가 끝난 후에 이 결과를 counter 레퍼런스에 더하는 것입니다. 이전에는 1000번의 아토믹 연산이 수행되지만, 아래처럼 코드를 수정하면 10번의 아토믹만 수행됩니다. void increment(int& counter) { int result{ 0 }; for (int i = 0; i < 100; i++) { ++result; std::this_thread::sleep_for(1ms); } counter += result; } 3.5 Watinig on Atomic Variables (C++20) C++20에는 아래 표의 메소드들이 std::atomic과 atomic_ref에 추가되었는데, 이를 사용하면 아토믹 변수가 수정될 때까지 효율적으로 기다릴 수 있습니다. 이 메소드는 다음과 같이 사용할 수 있습니다. int main() { std::atomic value{ 0 }; std::thread job{ [&value] { std::cout << "Thread starts waiting. "; value.wait(0); std::cout << "Thread wakes up, value = " << value << std::endl; } }; std::this_thread::sleep_for(2s); std::cout << "Main thread is going to change value to 1. "; value = 1; value.notify_all(); job.join(); } 실행 결과는 다음과 같습니다. 다음 포스팅에 이어서 멀티스레딩 프로그래밍에 대해 알아보겠습니다 ! [C++] 멀티스레딩 프로그래밍 (2)

코딩교육 티씨피스쿨

멀티 스레드

멀티 스레드(multi thread)

일반적으로 하나의 프로세스는 하나의 스레드를 가지고 작업을 수행하게 됩니다.

하지만 멀티 스레드(multi thread)란 하나의 프로세스 내에서 둘 이상의 스레드가 동시에 작업을 수행하는 것을 의미합니다.

또한, 멀티 프로세스(multi process)는 여러 개의 CPU를 사용하여 여러 프로세스를 동시에 수행하는 것을 의미합니다.

멀티 스레드와 멀티 프로세스 모두 여러 흐름을 동시에 수행하다는 공통점을 가지고 있습니다.

멀티 프로세스는 각 프로세스가 독립적인 메모리를 가지고 별도로 실행되지만, 멀티 스레드는 각 스레드가 자신이 속한 프로세스의 메모리를 공유한다는 점이 다릅니다.

멀티 스레드는 각 스레드가 자신이 속한 프로세스의 메모리를 공유하므로, 시스템 자원의 낭비가 적습니다.

또한, 하나의 스레드가 작업을 할 때 다른 스레드가 별도의 작업을 할 수 있어 사용자와의 응답성도 좋아집니다.

문맥 교환(context switching)

컴퓨터에서 동시에 처리할 수 있는 최대 작업 수는 CPU의 코어(core) 수와 같습니다.

만약 CPU의 코어 수보다 더 많은 스레드가 실행되면, 각 코어가 정해진 시간 동안 여러 작업을 번갈아가며 수행하게 됩니다.

이때 각 스레드가 서로 교체될 때 스레드 간의 문맥 교환(context switching)이라는 것이 발생합니다.

문맥 교환이란 현재까지의 작업 상태나 다음 작업에 필요한 각종 데이터를 저장하고 읽어오는 작업을 가리킵니다.

이러한 문맥 교환에 걸리는 시간이 커지면 커질수록, 멀티 스레딩의 효율은 저하됩니다.

오히려 많은 양의 단순한 계산은 싱글 스레드로 동작하는 것이 더 효율적일 수 있습니다.

따라서 많은 수의 스레드를 실행하는 것이 언제나 좋은 성능을 보이는 것은 아니라는 점을 유의해야 합니다.

스레드 그룹(thread group)

스레드 그룹(thread group)이란 서로 관련이 있는 스레드를 하나의 그룹으로 묶어 다루기 위한 장치입니다.

자바에서는 스레드 그룹을 다루기 위해 ThreadGroup이라는 클래스를 제공합니다.

이러한 스레드 그룹은 다른 스레드 그룹을 포함할 수도 있으며, 이렇게 포함된 스레드 그룹은 트리 형태로 연결됩니다.

이때 스레드는 자신이 포함된 스레드 그룹이나 그 하위 그룹에는 접근할 수 있지만, 다른 그룹에는 접근할 수 없습니다.

이렇게 스레드 그룹은 스레드가 접근할 수 있는 범위를 제한하는 보안상으로도 중요한 역할을 하고 있습니다.

예제 class ThreadWithRunnable implements Runnable { public void run() { try { Thread.sleep(10); // 0.01초간 스레드를 멈춤. } catch (InterruptedException e) { e.printStackTrace(); } } } public class Thread03 { public static void main(String[] args){ Thread thread0 = new Thread(new ThreadWithRunnable()); thread0.start(); // Thread-0 실행 System.out.println(thread0.getThreadGroup()); ThreadGroup group = new ThreadGroup(“myThread”); // myThread라는 스레드 그룹 생성함. group.setMaxPriority(7); // 해당 스레드 그룹의 최대 우선순위를 7로 설정함. // 스레드를 생성할 때 포함될 스레드 그룹을 전달할 수 있음. Thread thread1 = new Thread(group, new ThreadWithRunnable()); thread1.start(); // Thread-1 실행 System.out.println(thread1.getThreadGroup()); } } 코딩연습 ▶

실행 결과 java.lang.ThreadGroup[name=main,maxpri=10] java.lang.ThreadGroup[name=myThread,maxpri=7]

위의 예제처럼 main() 메소드에서 생성된 스레드의 기본 스레드 그룹의 이름은 “main”이 되며, 최대 우선순위는 10으로 자동 설정됩니다.

데몬 스레드(deamon thread)

데몬 스레드(deamon thread)란 다른 일반 스레드의 작업을 돕는 보조적인 역할을 하는 스레드를 가리킵니다.

따라서 데몬 스레드는 일반 스레드가 모두 종료되면 더는 할 일이 없으므로, 데몬 스레드 역시 자동으로 종료됩니다.

데몬 스레드의 생성 방법과 실행 방법은 모두 일반 스레드와 같습니다.

단, 실행하기 전에 setDaemon() 메소드를 호출하여 데몬 스레드로 설정하기만 하면 됩니다.

이러한 데몬 스레드는 일정 시간마다 자동으로 수행되는 저장 및 화면 갱신 등에 이용되고 있습니다.

가비지 컬렉터(gabage collector)

데몬 스레드를 이용하는 가장 대표적인 예로 가비지 컬렉터(gabage collector)를 들 수 있습니다.

가비지 컬렉터(gabage collector)란 프로그래머가 동적으로 할당한 메모리 중 더 이상 사용하지 않는 영역을 자동으로 찾아내어 해제해 주는 데몬 스레드입니다.

자바에서는 프로그래머가 메모리에 직접 접근하지 못하게 하는 대신에 가비지 컬렉터가 자동으로 메모리를 관리해 줍니다.

이러한 가비지 컬렉터를 이용하면 프로그래밍을 하기가 훨씬 쉬워지며, 메모리에 관련된 버그가 발생할 확률도 낮아집니다.

보통 가비지 컬렉터가 동작하는 동안에는 프로세서가 일시적으로 중지되므로, 필연적으로 성능의 저하가 발생합니다.

하지만 요즘에는 가비지 컬렉터의 성능이 많이 향상되어, 새롭게 만들어지는 대부분의 프로그래밍 언어에서 가비지 컬렉터를 제공하고 있습니다.

연습문제

멀티 쓰레드 프로그램 설계를 위한 8가지 규칙

‘The Art of Concurrency’ 책의 챕터 4 영문을 번역한 글입니다.

https://www.oreilly.com/library/view/the-art-of/9780596802424/ch04.html

이 책의 제목에 바로 포함되어 있기 때문에 다음 문장 은 놀라운 일이 아닙니다. 동시 프로그래밍은 여전히 과학보다 예술 입니다. 이 장에서는 스레딩 설계 방법 툴킷에 추가 할 수있는 간단한 8 가지 규칙을 제공합니다.

순서대로 규칙을 구성하려고 시도했지만 규칙에 대한 엄격한 순서는 없습니다. “수영장에서 달리기 금지”,“얕은 곳에서 다이빙 금지”에 대하는 것과 같습니다. 다이빙을 안하는 사건이 수영장에서 달리는 사건에 비해 먼저 일어나거나 반대 순서로도 일어날 수 있습니다.

이러한 규칙을 따르면 응용 프로그램의 가장 효율적인 스레드 구현을 작성하는 데 더 많은 성공을 거둘 수 있습니다. 이전 장에서 그 중 일부를 언급 했으므로이 중 일부를 인식 할 수 있습니다. 다음 장에서는 특정 알고리즘의 설계 및 구현에 대해 논의 할 때 여덟 개의 규칙 중 하나 이상에 대한 관련 참조를 추가하여 추가 장을 작성하기 위해 여기에 있지 않음을 보여 드리겠습니다.

규칙 1 : 완전히 독립적인 계산을 식별

필자는이 첫 번째 규칙을 일요일까지 7 가지 방법으로 이미 다루었지만 전체 문제의 요점이므로 적어도 한 번 더 반복해야합니다. 실행될 작업이 서로 독립적으로 실행될 수 없다면 어떤 것도 동시에 실행할 수 없습니다. 단일 목표를 달성하기 위해 수행되는 서로 다른 실제 행동 사례를 쉽게 생각할 수 있습니다. 예를 들어 DVD 대여 창고를 생각해봅시다. 영화 주문은 수집되어 Worker에게 배포됩니다. Worker는 모든 디스크가 저장된 위치로 이동하여 지정된 주문을 충족시키기 위해 사본을 찾습니다. 한 Worker가 클래식 뮤지컬 코미디를 꺼낼 때 최신 공상 과학 작품을 찾고있는 다른 Worker를 방해하지 않으며, 인기있는 범죄 드라마 시리즈의 두 번째 시즌에서 에피소드를 찾으려고하는 Worker를 방해하지도 않습니다 (주문이 창고로 전송되기 전에 재고가 없어서 발생하는 모든 충돌이 해결 된 것으로 가정합니다). 또한 각 주문의 포장 및 우편 발송은 디스크 검색이나 다른 주문의 운송 및 처리를 방해하지 않습니다.

동시에 수행 할 수없는 순차 순차 계산이있는 경우가 있습니다. 이들 중 다수는 특정 순서로 수행해야하는 루프 반복 또는 단계 간의 종속성입니다. 일반적인 상황 목록은 이전 의 병렬 기능에서 설명했습니다 .

규칙 2 : 가능한 가장 높은 수준에서 동시성 구현

직렬 코드 스레딩에 접근 할 때 취할 수있는 두 가지 방향이 있습니다. 이것들은 상향식 과 하향식 입니다. 코드를 처음 분석 할 때는 실행 시간이 가장 많은 계산 핫스팟을 찾고 있습니다. 이러한 부분을 병렬로 실행하면 가능한 최대 성능을 얻을 수있는 최상의 기회가 제공됩니다.

상향식 접근 방식에서는 코드에 핫스팟을 직접 스레딩하는 것이 좋습니다. 이것이 가능하지 않은 경우, 응용 프로그램의 호출 스택을 검색하여 코드에 핫스팟을 병렬로 실행할 수있는 다른 위치가 있는지 판별하십시오. 핫스팟이 중첩 루프 구조의 가장 안쪽 루프 인 경우, 가장 안쪽에서 바깥 쪽까지 루프 중첩의 각 연속 레이어를 검사하여 해당 레벨을 동시에 수행 할 수 있는지 확인하십시오. 핫스팟 코드에서 동시성을 사용할 수 있더라도 호출 스택에서 코드의 상위 위치에서 해당 동시성을 구현할 수 있는지 확인해야합니다. 이것은 각 스레드가 수행하는 실행의 세분성을 증가시킬 수 있습니다.

이 규칙을 설명하려면 비디오 인코딩 응용 프로그램을 스레딩하는 것이 좋습니다. 핫스팟이 개별 픽셀의 계산 인 경우 단일 비디오 프레임 내에서 각 픽셀 계산을 처리하는 루프를 병렬화 할 수 있습니다. 이것으로부터 더“위로”보면, 비디오 프레임에 대한 루프는 독립적으로 프레임 그룹을 처리함으로써 동시에 실행될 수 있습니다. 비디오 인코딩 응용 프로그램이 여러 비디오를 처리 할 것으로 예상되는 경우 각 스레드에 다른 스트림을 할당하여 동시성을 표현하는 것이 가능한 동시성 수준입니다.

스레딩에 대한 다른 접근 방식은 하향식으로, 먼저 전체 응용 프로그램과 계산을 수행하도록 코딩 된 항목 (해당 계산을 실현하기 위해 결합하는 응용 프로그램의 모든 부분)을 고려합니다. 명백한 동시성은 없지만, 여전히 핫스팟 실행을 포함하는 계산 부분을 독립적 인 계산을 식별 할 수있을 때까지 작은 부분으로 증류하십시오.

비디오 인코딩 응용 프로그램의 경우 핫스팟이 개별 픽셀의 계산 인 경우 하향식 접근 방식은 먼저 응용 프로그램이 여러 개의 독립적 인 비디오 스트림 (모두 픽셀 계산 포함)의 인코딩을 처리하는 것으로 간주합니다. 거기에서 응용 프로그램을 병렬화 할 수 있다면 가장 높은 수준을 발견 한 것입니다. 그렇지 않은 경우 개별 픽셀로“아래로”작업하면 단일 스트림 내의 프레임을 통과 한 다음 프레임 내의 픽셀로 이동합니다.

이 규칙의 목적은 코드 핫스팟이 동시에 실행될 수 있도록 동시성을 구현할 수있는 최고 수준을 찾는 것입니다. 이것은 알고리즘의 레이어에서 “높은”레벨이 유리의 파르페 레이어가 질량을 더 많이 축적하는 방식과 같이 더 많은 (독립적 인) 작업과 동일하다는 믿음을 전제로합니다. 핫스팟 주위에 가능한 한 높은 수준에서 동시성을 배치하는 것은 스레드에 할당 할 가장 중요한 굵은 단위 작업을 달성하는 가장 좋은 방법 중 하나입니다.

규칙 3 : 증가하는 코어 수를 활용하기 위한 확장성 조기 계획

이 글을 쓰는 동안 쿼드 코어 프로세서가 기본 멀티 코어 칩이되었습니다. 향후 프로세서에서 사용할 수있는 코어 수는 증가 할 것입니다. 따라서 소프트웨어 내에서 이러한 프로세서 증가를 계획해야합니다. 확장 성은 시스템 리소스 (예 : 코어 수, 메모리 크기, 버스 속도) 또는 데이터 세트 크기의 변경을 처리하고 일반적으로 증가하는 응용 프로그램의 기능을 측정 한 것입니다. 더 많은 코어를 사용할 수있는 상황에서는 다양한 수의 코어를 활용할 수있는 유연한 코드를 작성해야합니다.

C. Northcote Parkinson은 다음과 같이 설명합니다.“사용 가능한 처리 능력을 채우기 위해 데이터가 확장됩니다.”이것은 계산 능력이 증가할수록 (더 많은 코어) 처리 될 데이터가 확장 될 가능성이 높아짐을 의미합니다. 수행해야 할 계산이 항상 더 있습니다. 과학 시뮬레이션에서 모델 충실도를 높이거나 표준 비디오 대신 HD 스트림을 처리하거나 여러 개의 더 큰 데이터베이스를 검색하든 추가 처리 리소스가 제공되면 처리 할 데이터가 더 많은 사람이 있습니다.

데이터 분해 방법으로 동시성을 설계 및 구현하면보다 확장 가능한 솔루션이 제공됩니다. 작업 분해 솔루션은 응용 프로그램의 독립 함수 또는 코드 세그먼트 수가 실행 중에 제한되고 고정 될 수 있다는 사실로 인해 어려움을 겪을 것입니다. 각 독립 작업에 실행할 스레드와 코어가 있으면 더 많은 코어를 활용하기 위해 스레드 수를 늘려도 응용 프로그램의 성능이 향상되지 않습니다. 데이터 크기는 애플리케이션에서 독립적 인 계산 수보다 증가 할 가능성이 높으므로 데이터 분해 설계는 확장 가능성이 가장 높습니다.

독립 기능에 할당 된 스레드를 사용하여 응용 프로그램을 작성했지만 입력 워크로드가 증가하더라도 더 많은 스레드를 계속 사용할 수 있습니다. 유한 한 수의 개별 작업을 수행 할 수있는 식료품 점을 짓는 것을 고려하십시오. 개발자가 인접한 토지를 구입하고 매장의 바닥 공간을 두 배로 늘리면 이러한 작업 중 일부에 추가 근로자가 배정 될 수 있습니다. 즉, 여분의 화가, 여분의 지붕 꾼, 여분의 바닥 타일러 및 여분의 전기 기술자를 사용할 수 있습니다. 따라서 태스크로 분해 된 솔루션 내에서도 증가 된 데이터 세트에서 발생할 수있는 데이터 분해 가능성을 알고 여분의 코어에서 추가 스레드를 사용하도록 계획해야합니다.

규칙 4 : 가능하면 스레드 안전 라이브러리 사용

라이브러리 호출을 통해 핫스팟 계산을 실행할 수있는 경우 필기 코드를 실행하는 대신 동등한 라이브러리 함수를 사용하는 것이 좋습니다. 직렬 응용 프로그램의 경우에도 최적화 된 라이브러리 루틴으로 이미 캡슐화 된 계산을 수행하는 코드를 작성하여 “바퀴를 재창조”하는 것은 결코 좋은 생각이 아닙니다. Intel Math Kernel Library (MKL) 및 Intel IPP (Integrated Performance Primitives)와 같은 많은 라이브러리에는 멀티 코어 프로세서를 활용하기 위해 스레드 기능이 있습니다.

그러나 스레드 라이브러리 루틴을 사용하는 것보다 훨씬 중요한 것은 사용 된 모든 라이브러리 호출이 스레드 안전하다는 것입니다. 직렬 코드의 핫스팟을 라이브러리 함수에 대한 호출로 교체 한 경우에도 애플리케이션의 호출 트리에서 더 높은 지점이 독립적 인 계산으로 나눌 수 있습니다. 라이브러리 함수 호출, 특히 써드 파티 라이브러리를 실행하는 동시 계산이있는 경우 라이브러리 내의 공유 변수를 참조하고 업데이트하는 루틴으로 인해 데이터 경쟁이 발생할 수 있습니다. 동시 실행 내에서 사용중인 라이브러리의 스레드 안전성에 대해서는 라이브러리 문서를 확인하십시오. 동시에 실행될 자체 라이브러리 루틴을 작성하고 사용할 때 루틴이 재진입해야합니다. 이것이 가능하지 않은 경우

규칙 5 : 올바른 스레딩 모델 사용

스레드 라이브러리가 응용 프로그램의 모든 동시성을 처리하기에 충분하지 않고 사용자 제어 스레드를 사용해야하는 경우 암시적 스레딩 모델 (예 : OpenMP 또는 Intel Threading Building Blocks)에 필요한 모든 기능이있는 경우 명시적 스레드를 사용하지 마십시오. 명시적 스레드는 스레딩 구현을보다 세밀하게 제어 할 수 있습니다. 그러나 계산 집약적 루프 만 병렬화하거나 명시적 스레드로 얻을 수있는 추가 유연성이 필요하지 않은 경우에는 필요한 것보다 더 많은 작업을 수행 할 이유가 없습니다. 구현이 복잡할수록 실수를 저지르기 쉽고 나중에 이러한 코드를 유지하기가 더 어려워집니다.

OpenMP는 데이터 분해 방법, 특히 대규모 데이터 세트에 걸쳐있는 스레딩 루프를 대상으로합니다. 이것이 응용 프로그램에 도입 할 수있는 유일한 병렬 처리 유형 인 경우에도 OpenMP 사용을 금지하는 외부 요구 사항 (예 : 고용주 또는 관리 환경 설정에 따라 지정된 엔지니어링 관행)이있을 수 있습니다. 이 경우 승인 된 (명시 적) 모델로 스레딩을 구현해야합니다. 이러한 상황에서는 OpenMP를 사용하여 계획된 동시성을 프로토 타입하고 잠재적 인 성능 향상, 가능한 확장 성 및 명시 적 스레드로 직렬 코드를 스레딩하는 데 얼마나 많은 노력이 필요한지 추정하는 것이 좋습니다.

규칙 6 : 특정 실행 순서를 가정하지 마십시오

직렬 계산을 사용하면 프로그램의 다른 명령문에 따라 실행될 명령문을 쉽게 예측할 수 있습니다. 반면 스레드의 실행 순서는 비결정 적이고 OS 스케줄러에 의해 제어됩니다. 즉, 한 실행에서 다른 실행으로 실행되는 스레드의 순서를 예측하거나 다음에 실행될 스레드를 예측할 수있는 확실한 방법이 없습니다. 이는 특히 스레드보다 코어 수가 적은 시스템에서 실행될 때 응용 프로그램 내에서 실행 대기 시간을 숨기기 위해 수행됩니다. 캐시에 없거나 I / O 요청을 처리하기 위해 메모리가 필요하기 때문에 스레드가 차단되면 스케줄러는 차단 된 스레드를 교체하고 실행할 준비가 된 스레드를 교체합니다.

데이터 경쟁은 이러한 비결 정성 스케줄링의 직접적인 결과입니다. 다른 스레드가 해당 값을 읽기 전에 한 스레드가 값을 공유 변수에 기록한다고 가정하면 항상 옳을 수도 있고, 때때로 옳을 수도 있고, 그렇지 않을 수도 있습니다. 운이 좋으면 응용 프로그램을 실행할 때마다 특정 플랫폼에서 스레드 실행 순서가 변경되지 않는 경우가 있습니다. 시스템 (디스크의 비트 위치 또는 메모리 소켓 또는 벽면 소켓에서 나오는 AC 전원의 주파수) 사이의 모든 차이는 스레드 일정을 변경할 가능성이 있습니다. 긍정적 인 사고를 통해서만 시행되는 스레드 중 특정 실행 순서에 의존하는 코드는 데이터 경쟁 및 교착 상태와 같은 문제에 시달릴 수 있습니다.

성능 측면에서 볼 때 그레이하운드 나 레이스의 순종과 같이 스레드가 최대한 방해받지 않도록하는 것이 가장 좋습니다. 반드시 필요한 경우가 아니면 특정 실행 순서를 강요하지 마십시오. 절대적으로 필요한 시간을 인식하고 스레드의 실행 순서를 서로 조정하기 위해 동기화 형식을 구현해야합니다.

이어 달리기 경주를 생각해보세요. 첫 번째 주자는 가능한 빨리 달리기 시작합니다. 그러나 레이스를 성공적으로 완료하려면 두 번째, 세 번째 및 앵커 주자가 배턴을 받기 전에 대기해야합니다. 배턴 패스는 레이스에서 스테이지 간의 “실행”순서를 제어하는 연속 러너 간의 동기화입니다.

규칙 7 : 가능한 경우 스레드 로컬 스토리지 사용 또는 특정 데이터에 Lock 설정

동기화는 응용 프로그램의 병렬 실행에서 정확한 응답이 생성되도록 보장하는 것을 제외하고는 계산 향상에 기여하지 않는 오버 헤드입니다. 동기화는 필요한 악입니다. 그럼에도 불구하고, 동기화 양을 최소로 유지하기 위해 적극적으로 노력해야합니다. 스레드에 로컬 인 스토리지를 사용하거나 독점 메모리 위치 (예 : 스레드 ID로 색인화 된 배열 요소)를 사용하여이를 수행 할 수 있습니다.

임시 작업 변수는 스레드간에 거의 공유되지 않으며 각 스레드에 로컬로 선언되거나 할당되어야합니다. 각 스레드에 대해 부분 결과를 보유하는 변수는 스레드에 대해 로컬이어야합니다. 부분 결과를 공유 위치로 결합하려면 동기화가 필요합니다. 공유 업데이트가 가능한 한 드물게 수행되도록하면 오버 헤드 양이 최소로 유지됩니다. 명시 적 스레드를 사용하는 경우 사용 가능한 스레드 로컬 스토리지 API를 사용하여 한 스레드 영역에서 다른 스레드 영역으로 또는 스레드에서 하나의 스레드 함수 호출에서 동일한 함수의 다음 실행에 로컬로 데이터를 지속시킬 수 있습니다.

각 스레드에 대한 로컬 스토리지가 유효한 옵션이 아니며 동기화 오브젝트 (예 : 잠금)를 통해 공유 자원에 대한 액세스를 조정해야하는 경우 잠금을 데이터 항목에 올바르게 연관 (또는 “첨부”)해야합니다. 가장 쉬운 방법은 데이터 항목에 대한 일대일 (1 : 1) 잠금 관계를 갖는 것입니다. 항상 함께 액세스되는 여러 공유 변수가있는 경우 단일 잠금을 사용하여 이러한 변수와 관련된 모든 중요 영역에 독점적으로 액세스 할 수 있습니다. 이후 장에서는 특히 대규모 데이터 수집 (예 : 10,000 개 항목의 배열)에 대한 액세스를 보호해야하는 경우 사용할 수있는 일부 트레이드 오프 및 대체 동기화 기술에 대해 설명합니다.

그러나 잠금을 데이터 항목과 연관 시키려면 하나 이상의 잠금을 단일 데이터 오브젝트에 연관시키지 마십시오. 시걸의 법칙에 따르면“시계를 가진 사람은 몇 시인 지 알고 있습니다. 이 시계를 가진 사람은 결코 확실하지 않습니다. “두 개의 서로 다른 잠금, – 말 객체 경우 lockA와 lockB 같은 변수에 -protect 액세스 코드의 한 부분이 사용할 수 lockA 코드의 다른 부분은 사용할 수 있지만 액세스를 위해 lockB 이 두 코드 부분에서 실행되는 스레드는 데이터 레이스를 생성합니다. 각 레이스는 컨테스트 된 데이터에 독점적으로 액세스 할 수 있기 때문입니다.

규칙 8 : 동시성 향상을위한 알고리즘 변경

직렬 및 동시 애플리케이션의 성능을 비교할 때 가장 중요한 것은 벽시계 실행 시간입니다. 두 개 이상의 알고리즘 중에서 선택할 때 프로그래머는 점근 적 실행 순서에 의존 할 수 있습니다. 이 지표는 거의 항상 응용 프로그램의 상대 성능과 다른 성능의 상관 관계가 있습니다. 즉, 다른 모든 것이 일정하게 유지되면 Quicksort와 같은 O (n log n) 알고리즘 을 사용하는 응용 프로그램 은 O (n 2 ) 알고리즘 (예 : 선택 정렬) 보다 빠르게 실행 되며 동일한 결과를 생성합니다.

동시 응용 프로그램에서 더 나은 점근선 실행 순서를 가진 알고리즘도 더 빠르게 실행됩니다. 그럼에도 불구하고 최고의 직렬 알고리즘이 병렬화에 적합하지 않을 때가 있습니다. 핫스팟을 스레드 코드로 쉽게 전환 할 수없는 경우 (및 동시에 수행 할 수있는 핫스팟의 호출 스택에서 더 높은 지점을 찾을 수없는 경우) 현재 알고리즘이 아닌 차선책 직렬 알고리즘을 사용하여 변환하는 것을 고려해야합니다. 코드에서.

예를 들어, 두 정사각 행렬의 곱셈에 대한 선형 대수 연산을 고려하십시오. Strassen의 알고리즘은 가장 점근 적으로 실행되는 순서 중 하나 인 O (n 2.81 ) 입니다. 이것은 O (n 3 ) 보다 낫다 전통적인 트리플 네스트 루프 알고리즘. Strassen의 방법은 각 행렬을 4 개의 청크 (또는 하위 행렬)로 나누고 7 개의 재귀 호출을 사용하여 n / 2 × n / 2 하위 행렬을 곱합니다. 이러한 재귀 호출을 병렬화하기 위해 7 개의 독립적 인 서브 매트릭스 곱셈을 각각 실행할 새 스레드를 만들 수 있습니다. 실의 수는 기하 급수적으로 증가 할 것입니다 (세인트 아이브스에서 오는 아내, 자루, 고양이 및 새끼 고양이처럼). 서브 매트릭스가 점점 작아짐에 따라 새로 작성된 스레드에 지정된 할당 된 작업의 입도는 더 세밀 해집니다. 하위 행렬이 지정된 크기를 달성하면 직렬 알고리즘으로 전환하십시오.

행렬 곱셈을 병렬화하는 훨씬 쉬운 방법은 점증 적으로 열등한 3 중 루프 알고리즘을 사용하는 것입니다. 행렬에서 데이터 분해를 수행하고 (행으로 나누기, 열로 나누기 또는 블록으로 나누기) 여러 가지 방법으로 스레드에 필요한 계산을 할당 할 수 있습니다. 루프 수준 중 하나에서 OpenMP pragma를 사용하거나 필요에 따라 루프 인덱스 나누기를 구현하는 명시 적 스레드를 사용하여이 작업을 수행 할 수 있습니다. 더 간단한 직렬 알고리즘에는 코드 수정이 덜 필요하며 Strassen의 알고리즘을 스레드하려고 시도 할 때보 다 코드 구조가 손상되지 않을 수 있습니다. 더 좋은 방법은 간단한 규칙 4를 따르고 행렬-행렬 곱셈을 수행하는 동시 라이브러리 함수를 사용하는 것입니다.

요약

직렬 응용 프로그램을 동시 버전으로 변환하는 스레딩을 설계 할 때 명심해야 할 8 가지 간단한 규칙을 설명했습니다. 여기에 제시된 규칙을 따르면보다 강력하고 스레딩 문제가 발생할 가능성이 적으며 개발 시간이 줄어 최적의 성능을 향한 동시 솔루션을보다 쉽게 만들 수있었습니다. 여러분이 잘 해낼 것이라 믿습니다.

멀티스레드 프로그래밍

이 절에서는 스레드 라이브러리(libpthreads.a)를 사용하여 멀티스레드 프로그램을 작성하기 위한 지침을 제공합니다.

AIX® 스레드 라이브러리는 X/Open 이식성 가이드 문제 5 표준을 기반으로 합니다. 이러한 이유로 다음 정보는 스레드 라이브러리를 XPG5 표준의 AIX 구현으로 제공합니다.

병렬 프로그래밍에서는 기존 단일프로세서 시스템과의 완전한 2진 호환성을 유지하면서 멀티프로세서 시스템의 장점을 사용합니다. 병렬 프로그래밍 기능은 스레드 개념에 기초합니다.

병렬 프로그래밍은 프로그램 성능을 향상시킬 수 있습니다.

일부 공통 소프트웨어 모델은 병렬 프로그래밍 기술에 적합합니다. 직렬 프로그래밍 기술 대신 병렬 프로그래밍을 사용하는 경우 장점은 다음과 같습니다.

기존에는 복수의 단일 스레드 프로세스를 사용하여 병렬 처리를 수행하였지만 하는 데 사용되었지만 현재는 일부 프로그램에서만 병렬 처리를 통해 약간의 장점을 얻을 수 있습니다. 멀티스레드 프로세스는 프로세스 내의 병렬 처리를 제공하며 복수의 단일 스레드 프로세스 프로그래밍과 연관된 많은 개념을 공유합니다.

다음 정보에서는 스레드 및 이와 연관된 프로그래밍 기능을 소개합니다. 또한 병렬 프로그래밍과 관련된 일반 주제를 설명합니다.

[운영체제(OS)] 4. 멀티쓰레드(Multithreaded Programming)

728×90

반응형

[목차]

1. Thread

2. Multithreading

3. User-level Thread vs Kernel-level Thread

4. Threading Issues

참고)

– https://parksb.github.io/article/8.html

– KOCW 공개강의 (2014-1. 이화여자대학교 – 반효경)

– Sogang Univ. Operating System Lecture Note (2018-2. Prof. Youngjae Kim)

1. Thread

스레드(Thread)는 CPU 수행의 기본 단위 또는 프로세스 안의 제어권의 흐름이다. 스레드가 수행되는 환경을 Task라고 부르는데, 전통적인 프로세스는 하나의 스레드가 있는 Task와 일치한다.

스레드는 Thread ID, Program counter, Register set, Stack space로 구성된다. 각각의 스레드는 주로 최소한 자신의 레지스터 상태와 스택을 갖는다.

반면 Code, Data 섹션이나 운영체제 자원들은 스레드끼리 공유한다. 아래 그림을 참고해보자.

한 프로세스가 하나의 스레드를 이용하여 한 번에 한 작업만 수행하는 것은 싱글 스레드(Single thread), 한 프로세스가 여러 스레드로 동시에 여러 작업을 수행하는 것은 멀티 스레드(Multi thread)라고 한다. 프로세스 내의 스레드는 모두 각각 독립적인 실행 파일이며, 모든 스레드는 프로세스의 일부이다. 프로세스를 여러 개 수행해도 되지만 굳이 스레드를 사용하는 이유는 다음과 같다.

1. 프로세스를 생성하거나 Context switching 하는 작업은 너무 무겁고 잦으면 성능 저하가 발생하는데, 스레드를 생성하거나 switching 하는 것은 그에 비해 가볍다.

2. 두 프로세스가 하나의 데이터를 공유하려면 메시지 패싱이나 공유 메모리 또는 파이프를 사용해야 하는데, 이는 효율도 떨어지고 개발자가 구현, 관리하기도 번거롭다.

2. Multithreading

프로세서가 여러 개인 경우 멀티 스레드를 통해 병렬성(Parallelism)을 높일 수 있다. 즉, 여러 작업이 동시에 수행될 수 있다.

이는 프로세스의 스레드들이 각각 다른 프로세서에서 병렬적으로 수행될 수 있기 때문이다. 병렬성은 CPU의 개수에 비례한다.

만약 프로세서가 하나인 경우 엔 멀티 스레드를 통해 동시성(Concurrency)을 높일 수 있다. 실제로는 각각의 시간에 한 작업만 수행되지만, 병렬적으로 수행되는 것처럼 보이는 것이다. 만약 한 스레드가 blocked(waiting) 되더라도 커널이 다른 스레드로 switch 시켜 실행할 수 있어서, 하나의 프로세서임에도 불구하고 빠른 처리가 가능하고 계산 속도가 증가한다.

멀티스레딩의 장점은 뭘까?

1. 응답성(Responsiveness)

싱글 스레드인 경우, 작업이 끝나기 전까지 사용자에게 응답하지 않는다. 반면 멀티스레드인 경우 작업을 분리해서 수행하므로 실시간으로 사용자에게 응답할 수 있다.

2. 자원 공유(Resource sharing)

프로세스는 오직 공유 메모리나 메시지 패싱을 이용해서 자원을 공유할 수 있지만, 스레드는 자신이 속한 프로세스 내의 스레드들과 메모리나 자원을 공유하여 효율적으로 사용할 수 있다.

3. 경제성(Economy)

프로세스를 새로 생성하는 비용보다 스레드를 새로 생성하는 게 훨씬 싸다. 그리고 Context switching의 오버헤드 또한 스레드가 더 경제적이다. 실제로 Solaris에서 프로세스 생성은 스레드 생성보다 30배 느리고, switching은 5배 느리다.

4. 확장성(Scalability)

싱글 스레드인 경우 한 프로세스는 오직 한 프로세서에서만 수행 가능하다. 반면 멀티 스레드인 경우 한 프로세스를 여러 프로세서에서 수행할 수 있으므로 훨씬 효율적이다.

3. User-level Thread vs Kernel-level Thread

유저 스레드(User-level Thread)는 커널 위에서 커널의 지원 없이 유저 수준의 스레드 라이브러리(Thread Library)가 관리하는 스레드다. 반면 커널 스레드(Kernel-level Thread)는 커널이 지원하는 스레드다.

커널 스레드를 사용하면 안정적이지만 유저 모드에서 커널 모드로 계속 바꿔줘야 하기 때문에 성능이 저하된다. 반대로 유저 스레드를 사용하면 안정성은 떨어지지만 성능이 저하되지는 않는다.

유저 스레드와 커널 스레드 사이에 어떠한 관계가 항상 존재한다. 이 관계를 설계하는 여러 가지 방법이 있다.

1. Many-to-One Model

하나의 커널 스레드에 여러 유저 스레드를 연결하는 모델이다. 유저 공간의 스레드 라이브러리를 통해서 스레드가 관리되므로 효율적이다. 라이브러리를 위한 모든 코드나 자료구조가 유저 공간에 존재하므로 라이브러리의 함수 호출이 시스템 콜이 아니라 지역 함수 호출의 결과를 낳기 때문이다.

반면, 한번에 한 유저 스레드만 커널에 접근할 수 있기 때문에 멀티 프로세서 시스템에서 병렬적인 수행을 할 수 없어 요즘에는 잘 사용되지 않는 방식이다. 한 유저 스레드의 시스템 콜로 인해 block 되면 프로세스 전체가 block 되기 때문이다.

2. One-to-One Model

하나의 커널 스레드에 하나의 유저 스레드가 대응하는 모델이다. 동시성(Concurrency)을 높여주고, 멀티 프로세서 시스템에서 동시에 여러 스레드를 수행할 수 있도록 해준다.

단점으로는, 유저 스레드를 늘리면 커널 스레드도 똑같이 늘어나는데, 커널 스레드의 생성은 오버헤드가 크기 때문에 성능 저하가 발생할 수 있다.

3. Many-to-Many Model

여러 유저 스레드에 더 적거나 같은 수의 커널 스레드가 대응하는 모델이다. 운영체제는 충분한 수의 커널 스레드를 만들 수 있으며, 커널 스레드의 구체적인 개수는 프로그램이나 작동 기기에 따라 다르다. 멀티 프로세서 시스템에서는 싱글 프로세서 시스템보다 더 많은 커널 스레드가 만들어진다.

완전한 동시성은 아니지만, Many-to-one Model에 비해 더 높은 동시성을 갖는다. 그리고 One-to-One Model의 단점이었던 커널 스레드 생성의 오버헤드도 걱정할 필요 없다.

4. Two-level Model

Many-to-Many Model에서 확장된 개념이다. 특정 유저 스레드를 위한 커널 스레드를 별도로 제공하는 모델이다. 점유율이 높아야 하는 유저 스레드를 더 빠르게 처리할 수 있다.

4. Threading Issues

Multi-threaded 프로그램을 디자인할 때 고려해야 할 몇 가지가 있다.

1. Semantics of fork( ) and exec( ) system calls

만약 fork( ) 이후에 exec( )을 바로 호출한다면 exec( )으로 인해 스레드를 포함한 전체 프로세스가 대체되기 때문에 모든 스레드를 복제하는 것은 불필요할 것이다. 그렇지 않으면 모든 프로세스를 복제해야 한다.

따라서 몇몇 UNIX 시스템은 두 버전의 fork( )를 가진다.

2. Signal Handling

시그널(Signal)은 특정한 사건이 발생했다고 프로세스에게 알려주기 위해 UNIX 시스템에서 사용하는 것이다. 자원이나 시그널의 원인에 따라 두 종류로 나뉜다.

1) Synchronous signals

– 시그널을 일으킨 작업을 수행한 프로세스에 전달된다. (ex. division by 0, illegal memory access)

2) Asynchronous signals

– 수행중인 프로세스의 외부 사건에 의해 만들어진다. (ex. Ctrl+C과 같은 특정 키 입력으로 인한 종료, 타이머 종료)

시그널을 다루는 방법 또한 다양하다.

싱글 스레드 프로그램에서는 시그널이 특정 사건에 의해 생성되고, 프로세스에 전달된 후 다뤄진다.

멀티 스레드 프로그램에서는 시그널을 제공한 스레드로 시그널이 전달되거나(ex. Synchronous signals), 프로세스 내의 모든 스레드에 전달되거나(ex. process termination signal), 프로세스 내의 특정한 스레드에 전달될 수 있다(some asynchronous signals to non-blocking threads). 혹은 프로세스의 모든 시그널을 전달받는 특별한 스레드를 할당하는 방법도 있다.

3. Thread Cancellation

스레드가 끝나기 전에 종료시키는 두 방식이 있다.

1) Asynchronous cancellation : 목표 스레드(Target thread)를 즉시 종료시킨다.

2) Deferred cancellation : 목표 스레드가 종료되어야 하는지 주기적으로 체크한다.

4. Thread Pools

스레드를 요청할 때마다 매번 새로운 스레드를 생성, 수행, 삭제를 반복하면 성능이 저하된다. 따라서 미리 스레드 풀(Thread pools)에 여러 스레드를 만들어두고 요청이 오면 스레드 풀에 기존에 존재하던 스레드를 할당해주는 방법을 사용한다.

새 스레드를 만드는 것보다 기존에 존재하는 스레드를 사용하는 것이 약간 더 빠르고, 많은 양의 스레드를 일정한 크기의 pool 안에 묶어둘 수 있는 장점이 있다.

5. Thread Local Storage

각각의 스레드들이 자신의 영역을 만들어 관리할 수 있도록 해주는 것이다. static data와 유사하다.

로컬 변수(local variable)와 혼동하면 안 된다. 로컬 변수는 한 함수가 수행되는 동안만 visible 하다.

PC로 보시는 것을 권장합니다.

피드백은 언제나 환영입니다. 댓글로 달아주세요 ^-^

728×90

반응형

키워드에 대한 정보 멀티 스레드 프로그래밍

다음은 Bing에서 멀티 스레드 프로그래밍 주제에 대한 검색 결과입니다. 필요한 경우 더 읽을 수 있습니다.

이 기사는 인터넷의 다양한 출처에서 편집되었습니다. 이 기사가 유용했기를 바랍니다. 이 기사가 유용하다고 생각되면 공유하십시오. 매우 감사합니다!

사람들이 주제에 대해 자주 검색하는 키워드 초보 탈출 #2 – 멀티 스레드 프로그래밍의 1

  • 스레드
  • 패턴

초보 #탈출 ##2 #- #멀티 #스레드 #프로그래밍의 #1


YouTube에서 멀티 스레드 프로그래밍 주제의 다른 동영상 보기

주제에 대한 기사를 시청해 주셔서 감사합니다 초보 탈출 #2 – 멀티 스레드 프로그래밍의 1 | 멀티 스레드 프로그래밍, 이 기사가 유용하다고 생각되면 공유하십시오, 매우 감사합니다.

See also  족 과 의 동침 둘 루스 메뉴 | [애틀랜타Tv] Zen Korean Bbq 애틀랜타 둘루스의 새 맛집!!! 가격과 맛 모두 만족!!! Atl 맛집탐방 25381 좋은 평가 이 답변