-
java 멀티스레딩대학/객체지향프로그래밍 2022. 10. 18. 23:40
- thread
프로그램이 메모리에 적재가 되어 운영체제의 관리를 받게 되면 프로세스가 되는데,
이 프로세스의 실행 흐름을 스레드 라고 한다.
스레드는 프로세스에 비해 필요한 자원이 적고(그래서 경량 프로세스라 불림),
자원을 공유할 수 있다(프로세스는 공유 메모리를 제외하고 물리적으로 자원을 공유하지는 않는다).
이런 스레드를 이용해서 운영체제는 프로세스 기반 멀티 테스킹 뿐 만 아니라,
스레드 기반 멀티 테스킹을 할 수 있게 된다. (이를 멀티 스레딩 이라고도 부른다)
멀티 스레딩은 네트워크 통신이나, 인터랙티브 프로그램에서 자주 발생하는
유휴시간(idle time)을 최소화하여 사용자 경험을 올려줄 수 있다.
java의 특징으로 멀티 스레딩을 JVM에서 지원하기 때문에 언어 레벨에서 멀티 스레딩 구현이 가능하다.
(c의 경우에는 언어 레벨에서 지원하지 않기에, PoSIX같은 서드파티 라이브러리를 사용해야 한다)
또한, 비동기식 스레드 모델을 사용하기 때문에, 한 스레드가 블록 되더라도 다른 스레드에는 영향을 주지 않는다.
하지만, 때로는 스레드 간에 동기화가 필요한 경우가 있다. (데이터 구조를 공유하거나, 스레드간 통신을 할 때)
따라서 java는 모니터 메커니즘을 이용하여 동기화를 구현한다.
- monitor
java의 모든 객체에는 Monitor 객체가 붙어있다.
스레드가 객체에 접근하기 위해서는 Monitor를 점유하여 객체에 lock을 걸어야 접근이 가능하다.
따라서 다른 스레드가 객체에 접근하려고 해도, 이미 Monitor가 점유 중이라 객체에 lock이 걸려있기 때문에 접근할 수 없고,
스레드의 점유가 끝날 때 까지 대기하게 하여 동기화를 달성한다.
- java Thread
java 프로그램 실행시 기본적으로 Main Thread가 생성된다.
그리고 개발자가 자식 스레드를 생성할 수 있다.
스레드의 생성 방법은 크게 두 가지로 나뉜다.
1. Runnable 인터페이스 구현 클래스 정의
class NewThread implements Runnable { Thread t; NewThread() { t = new Thread(this); System.out.println("Child thread: " + t); } public void run() { try { for (int i = 5; i > 0; i--) { System.out.println("Child Thread: " + i); Thread.sleep(500); } } catch (InterruptedException e) { System.out.println("Child interrupted."); } System.out.println("Exiting child thread."); } } ... NewThread nt = new NewThread(); nt.t.start();
2. Thread 클래스를 상속하여 클래스 정의
class NewThread extends Thread { NewThread() { super(); System.out.println("Child thread: " + this); } public void run() { try { for (int i = 5; i > 0; i--) { System.out.println("Child Thread: " + i); Thread.sleep(500); } } catch (InterruptedException e) { System.out.println("Child interrupted."); } System.out.println("Exiting child thread."); } } ... NewThread nt = new NewThread(); nt.start();
두 방법 모두 같은 기능을 수행한다.
run() 외에 메서드를 overriding할 필요가 있다면 Thread 클래스를 상속받아 만드는 것이 좋고,
run() 메서드만 구현하거나, 다른 클래스를 상속해야할 상황이라면 Runnable 인터페이스를 구현하여 만드는 것이 좋다.
스레드를 생성했다고 끝이 아니다.
앞서 언급했 듯, 스레드는 비 동기식으로 작동하기 때문에, 자식 스레드가 동작 중에 메인 스레드가 종료되는 일이 있을 수 있다.
치명적이진 않지만, 메인 스레드가 먼저 종료되면 논리적으로 원치 않은 결과가 나올 수도 있다.
따라서 메인 스레드가 마지막에 종료되도록 보장하는 스레드 메서드가 있다.
바로 join 메서드이다.
CashierTask ct[] = new CashierTask[n]; ... ct[i] = new CashierTask(q.pop()); ct[i].start(); ... for (int i = 0; i < n; i++) { if (ct[i] != null && ct[i].isAlive()) ct[i].join();
위 코드는 n개의 스레드 배열을 생성한 뒤, 각 요소에 스레드를 만들어 실행한 상태이다.
마지막에 for문을 돌며 스레드 배열을 검사하는데, isAlive 메서드를 통해 스레드가 동작중인지를 검사한다.
만약 스레드가 동작 중이라면, join 메서드를 사용해 main 메서드의 실행을 일시적으로 중단한다.
즉, ct[i].join(); 다음 줄부터 메인 스레드 코드의 실행이 멈췄다가 ct[i] 스레드가 종료되면 다시 실행되게된다.
- thread priority
중요하거나 빈번하게 수행해야 하는 기능을 수행하는 스레드는 더 많은 CPU의 관심(?)을 받아야 한다.
그런 스레드의 우선 순위를 정하는 방법은 다음과 같은 함수를 사용하면 된다.
tr1.t.setPriority(10); // Runnable 구현체 클래스 스레드 tr2.setPriority(1); // Thread 상속받은 클래스 스레드
setPriority 함수의 인자로 int 타입 레벨을 넘겨주는데, 10은 최고 우선순위를, 1을 최저 우선순위를 나타낸다.
Thread 클래스에서 MAX_PRIORITY, NORM_PRIORITY, MIN_PRIORITY 상수를 제공하는데,
각각 10, 5, 1의 우선순위를 갖는다.
- thread synchronized
class Callme { void call(String msg) { System.out.print("[" + msg); try { Thread.sleep(1000); } catch (InterruptedException e) { System.out.println("Interrupted"); } System.out.println("]"); } } class Caller implements Runnable { String msg; Callme target; Thread t; public Caller(Callme targ, String s) { target = targ; msg = s; t = new Thread(this); } public void run() { target.call(msg); } } class Synch { public static void main(String[] args) { Callme target = new Callme(); Caller ob1 = new Caller(target, "Hello"); Caller ob2 = new Caller(target, "World"); ob1.t.start(); ob2.t.start(); try { ob1.t.join(); ob2.t.join(); } catch (InterruptedException e) { System.out.println("Interrupted"); } } } 실행 결과 [World[Hello] ] 또는 [Hello[World] ]
Caller 라는 스레드 구현체 클래스에서 Callme 라는 공유 자원(객체)를 호출하여 사용하게 된다.
메인 함수에서 스레드를 start() 하면 각 스레드에서 run() 함수 내부의 코드를 수행하게 되는데,
이 때, 각 스레드는 Callme 클래스에 call 메서드를 사용하게 된다.
하지만, 스레드가 call을 동시에 접근하는 바람에 call에 대한 레이스 컨디션이 발생해 위와 같은 이상한 결과가 출력되었다.
이렇게 스레드가 공유 자원을 동시에 접근하는 일을 막도록 공유 하는 자원에 대해서는 임계영역(critical section)을 설정해 줘야 한다.
임계영역에 스레드가 진입하게 되면 모니터를 점유, 락을 걸게 되어 다른 스레드가 접근하는 것을 막을 수 있기 때문이다.
임계영역을 설정하는 키워드는 synchronized 키워드를 사용하면 되는데,
synchronized 키워드를 사용하는 위치에 따라 불리는 방식이 약간 달라진다.
class Callme { synchronized void call(String msg) { System.out.print("[" + msg); try { Thread.sleep(1000); } catch (InterruptedException e) { System.out.println("Interrupted"); } System.out.println("]"); } }
위와 같이 메서드 앞에 synchronized 키워드를 붙이면 해당 메서드는 동기화 메서드가 되며,
메서드 안의 영역은 임계영역이 된다.
하지만, 동기화 메서드는 클래스 내부의 코드 접근이 필연적으로 필요하기 때문에, 서드 파티 클래스는 사용할 수 없다.
class Caller implements Runnable { String msg; Callme target; Thread t; public Caller(Callme targ, String s) { target = targ; msg = s; t = new Thread(this); } public void run() { synchronized (target) { target.call(msg); } } }
위와 같이 객체를 참조하도록 synchronized 키워드를 사용하면 참조한 객체가 모니터 객체가 되어 동기화되며,
중괄호 안의 부분이 임계영역이 된다.
- 스레드 간 통신
자원 생성자 스레드, 소비자 스레드가 하나의 공유 자원을 공유할 때,
공유 자원이 있다면, 소비자 스레드가 접근하도록 하고, 공유 자원이 없다면 생성자 스레드가 접근하도록 하는 방법이 없을까?
이럴 때 스레드간 통신을 구현할 수 있도록 도와주는 메서드가 있다.
wait() 메서드는 이 메서드를 호출한 스레드가 락을 포기하고 잠들도록 한다.
notify() 메서드는 wait()을 호출한 스레드 중 하나를 깨우게 한다.
notifyAll() 메서드는 wait()을 호출한 스레드 모두를 꺠우게 한다.
사용 법을 알아보기 위해 다음의 코드를 살펴보자.
class Q { int n; boolean valueSet = false; synchronized int get() { try { if (!valueSet) wait(); } catch (InterruptedException e) { System.out.println("InterruptedException"); } System.out.println("Got: " + n); valueSet = false; notify(); return n; } synchronized void put(int n) { try { if (valueSet) wait(); } catch (InterruptedException e) { System.out.println("InterruptedException"); } this.n = n; valueSet = true; System.out.println("Put: " + n); notify(); } }
자원 생성자 스레드는 Q 클래스의 put 메서드만 호출하고,
자원 소비자 스레드는 Q 클래스의 get 메서드만 호출하도록 구현 되어 있는데 (코드 상엔 없음),
만약 자원이 비어있다면?
자원 소비자 스레드가 먼저 접근 했어도 valueSet이 false 이기 때문에 wait 메서드를 만나게 되고
소비자 스레드는 sleep 상태로 전환되고 락을 포기하게 된다.
동시에 자원 생성자 스레드는 접근시 valueSet이 false이기 때문에 wait을 건너 뛰고 n에 값을 넣은 뒤,
valueSet을 true로 바꾸고 notify를 통해 자원 소비자 스레드를 깨우게 된다.
또한 자원 생성자 스레드는 다시 접근하게 되는데, 이 떄는 valueSet이 true이기 때문에 wait을 만나고 잠이 들게된다.
동시에 자원 소비자 스레드는 깨어나 valueSet을 다시 false로 바꾸고 notify를 통해 자원 생성자 스레드를 깨운다.
또한 자원 소비자 스레드는 다시 접근하게 되는데, 이 때 valueSet이 또 false이기 때문에 wait을 만나고 잠이 들게된다.
이 과정이 반복되면서 자원 생성자-소비자 스레드는 번갈아 n의 값을 참조하게 된다.
그림으로도 설명해 보았습니다.
- Deadlock
멀티 스레딩 환경에서 발생할 수 있는 대표적인 문제로,
두 스레드가 공유 자원에 대해 서로를 기다리는 상태로 두 스레드가 영원히 대기상태에 빠지는 상황을 말한다.
의도적으로 이 상황을 구현할 수 있지만, 의도 하지 않아도 확률적으로 발생할 수 있기 때문에 디버깅이 어렵다.
- Thread life cycle
스레드는 위와 같은 상태를 갖는 생명 주기가 있는데, 이를 조작하기 위한 메서드도 제공된다.
메서드 설명 void interrupt() 실행 중인 스레드에 인터럽트를 걸어 중지시킨다. void join() 주어진 시간이 지나거나 대응하는 스레드가 종료될 때까지 대기시킨다. void resume() 중지 상태의 스레드를 실행 대기 상태로 전환시킨다. static void sleep() 주어진 시간동안 중지한다. void start() 스레드를 실행 대기시킨다. void stop() 스레드를 종료한다. void suspend() 스레드를 중지한다. static void yield() 우선순위가 동일한 스레드에 실행을 양보한다. '대학 > 객체지향프로그래밍' 카테고리의 다른 글
java I/O (0) 2022.12.09 java 열거형 / 박싱 / 어노테이션 (0) 2022.12.09 java 예외 처리 (0) 2022.10.09 java 인터페이스 (0) 2022.10.09 java 패키지 (0) 2022.10.09