쓰레드와 동기화

gustjtmd·2022년 1월 21일
0

Java

목록 보기
40/40

쓰레드의 이해와 쓰레드의 생성

쓰레드의 이해와 쓰레드의 생성 방법

쓰레드는 실행 중인 프로그램 내에서 '또 다른 실행의 흐름을 형성하는 주체'를 의미한다.
다음과 같이 프로그램을 실행하면 가상머신은 하나의 쓰레드를 생성해서 main 메소드의
실행을 담당하게 된다.

public class CurrentThreadName {
    public static void main(String[] args) {
        Thread ct = Thread.currentThread();
        String name = ct.getName(); //쓰레드의 이름을 반환
        System.out.println(name);
    }
}

출력 : 
main


--------------------------------------------------------------------------
위 코드의 다음 문장을 main 메소드를 실행하는 쓰레드를 지정하는
(main 메소드를 실행하는 쓰레드의 정보를 담고있는) 인스턴스의 참조를 얻을 수 있다.

Thread ct = Thread.currentThread();

그리고 쓰레드도 이름이 있는데 다음 문장을 통해서 참조하는 쓰레드의 이름을 얻을 수 있다.

String name = ct.getName();

출력 결과를 보면 쓰레드의 이름이 main 임을 알 수 있다.
main 메소드를 실행하는 쓰레드를 가리켜 'main 쓰레드'라 한다.

쓰레드를 생성하는 첫번째 방법

main 쓰레드 이외의 쓰레드를 생성해보고 확인해보자.

public class MakeThreadDemo {
    public static void main(String[] args) {
        Runnable tast = () -> {
            int n1 = 10;
            int n2 = 20;
            String name = Thread.currentThread().getName();
            System.out.println(name +": "+(n1+n2));
        };

        Thread t = new Thread(tast);
        t.start();
        System.out.println("End : "+ Thread.currentThread().getName());
    }
}



End : main
Thread-0: 30

-------------------------------------------------------------------------

쓰레드의 생성을 위해 선 Runnable 인터페이스를 구현하는 클래스의 인스턴스를 생성하는 일이다.
Runnable은 다음 추상 메소드 하나만 존재하는 함수형 인터페이스이다.

void run()

따라서 다음과 같이 람다식을 기반으로 메소드의 구현과 인스턴스의 생성을 동시에 진행하였다.
이렇게 구현된 메소드는 새로 생성되는 쓰레드에 의해 실행되는 메소드이다.

        Runnable tast = () -> {
            int n1 = 10;
            int n2 = 20;
            String name = Thread.currentThread().getName();
            System.out.println(name +": "+(n1+n2));
        };
        
Runnable을 구현하였다면, 이를 전달하며 다음과 같이 Thread 인스턴스를 생성해야 한다.

Thread t = new Thread(tast); //인스턴스 생성시 run 메소드의 구현 내용 전달

마지막으로 Thread 인스턴스를 대상으로 start 메소드를 호출하는것

t.start();

그러면 가상머신은 쓰레드를 생성해서 Thread 인스턴스 생성시 전달된 run 메소드를 
실행하게 한다. 
이렇게 생성된 쓰레드의 이름이 Thread-0으로 출력되었는데 이는 기본적으로 주어진 이름
따라서 별도의 이름을 붙여주고 싶다면 다음 생성자를 통해 Thread 인스턴스를 생성하면 된다.

public Thraed(Runnable target, String name)

또한 실행 결과에서 main 쓰레드가 먼저 일을 마친 상황을 보이고 있다.
쓰레드의 생성에는 시간이 걸리므로 이러한 상황은 쉽게 연출된다.
main 쓰레드가 일을 마쳤다고 프로그램은 종료되지 않고 모든 쓰레드가 일을 마치고 소멸 되어야
프로그램이 종료된다.
참고로 생성된 쓰레드는 자신의 일을 마치면 자동으로 소멸된다.

"쓰레드는 자신의 일을 마치면(run 메소드의 실행을 완료하면) 자동으로 소멸된다.

쓰레드를 생성하는 두번째 방법

앞서 설명한 쓰레드의 생성 과정을 세 단계로 정리하면 다음과 같다.
1. Runnable을 구현한 인스턴스 생성
2. Thread 인스턴스 생성
3. start 메소드 호출

그러나 다음 두 단계를 거쳐서 쓰레드를 생성하는 방법도 있다.
1. Thread를 상속하는 클래스의 정의와 인스턴스 생성
2. start 메소드 호출

즉 'Runnable을 구현한 인스턴스 생성''Thread 인스턴스 생성''Thraed를 상속하는 클래스의 인스턴스 생성' 으로 대체할수 있다. 다음 코드를 확인해보자

class Task extends Thread{
    public void run(){
        int n1 = 10;
        int n2 = 20;
        String name = Thread.currentThread().getName();
        System.out.println(name + ": "+(n1+n2));
    }
}
public class MakeThreadDemo2 {
    public static void main(String[] args) {
        Task t1 = new Task();
        Task t2 = new Task();
        t1.start();
        t2.start();
        System.out.println("End : "+Thread.currentThread().getName());
    }
}


End : main
Thread-1: 30
Thread-0: 30

위 코드에서 보이듯이 Thread를 상속하는 클래스는 다음 메소드를 오버라이딩 해야한다.

public void run()

그러면 start 메소드 호출을 통해 쓰레드가 생성되었을때 이 쓰레드는 오버라이딩 된
run 메소드를 실행하게 된다.

두개의 쓰레드를 생성해서 실행해보기

두개의 쓰레드를 생성해서 실행 속도를 조금 늦춰 동시에 실행되는 상황을 관찰해보자.

public static void sleep(long millis) throws InterruptedException
인자로 전달된 값의 millisecond 만큼(1/1000) 실행을 멈추고 잠을 자게 된다.

public class MakeThreadMultiDemo {
    public static void main(String[] args) {
        Runnable tast1 = () -> {
            try{
                for(int i = 0; i < 20; i++){    //20미만 짝수 출력
                    if(i % 2 == 0)
                        System.out.print(i+ " ");
                    Thread.sleep(100);  //0.1초 잠들기
                }
            }catch (InterruptedException e){
                e.printStackTrace();
            }
        };
        Runnable tast2 = () -> {
            try {
                for(int i = 0; i < 20; i++){    //20미만 홀수 출력
                    if(i % 2 ==1)
                        System.out.print(i + " ");
                    Thread.sleep(100);  // 0.1초 잠들기
                }
            }catch (InterruptedException e){
                e.printStackTrace();
            }
        };

        Thread t1 = new Thread(tast1);
        Thread t2 = new Thread(tast2);
        t1.start();
        t2.start();
    }
}



0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 


---------------------------------------------------------------------

한 쓰레드는 20 미만의 짝수를, 다른 한 쓰레드는 20 미만의 홀수를 출력한다.
그리고 값을 출력할때마다 0.1초씩 잠이 든다. 따라서 0부터 19까지 1씩 증가하여 출력이 된다.
보통 쓰레드 하나에 cpu 코어 하나가 할당되어 동시에 실행이 이뤄진다.
동시에 이뤄진 쓰레드를 대상으로 위 코드에서 보이는 수준으로 실행 흐름을 조절하거나
예측하는 것은 잘못된 결과로 이어지기 쉽다. 쓰레드가 처한 상황에 따라서 또는 운영체제가 
코어를 쓰레드에 할당하는 방식에 따라서 두 쓰레드의 실행 속도에는 차이가 있기 때문이다.

이러한 상황을 확인하기 위해 sleep 메소드 호출을 생략해보자

    public static void main(String[] args) {
        Runnable tast1 = () -> {
            for(int i = 0; i < 20; i++){    //20미만 짝수 출력
                if(i % 2 == 0)
                    System.out.print(i+ " ");
            }
        };
        Runnable tast2 = () -> {
            for(int i = 0; i < 20; i++){    //20미만 홀수 출력
                if(i % 2 ==1)
                    System.out.print(i + " ");
            }
        };

        Thread t1 = new Thread(tast1);
        Thread t2 = new Thread(tast2);
        t1.start();
        t2.start();
    }
}


1 3 5 7 9 11 13 15 17 19 0 2 4 6 8 10 12 14 16 18 
0 2 4 1 3 5 7 6 8 10 9 12 14 11 13 15 17 16 18 19 
1 3 5 0 2 4 7 9 11 13 6 8 15 17 19 10 12 14 16 18

-------------------------------------------------------------------
세번의 실행 결과를 보였는데 세 번 모두 실행 결과가 다르다.
이것이 쓰레드의 실행 특성이다. 각각의 쓰레드는 이렇듯 독립적으로 자신의 일을 실행해 나간다.

쓰레드의 동기화

쓰레드의 메모리 접근 방식과 그에 따른 문제점

다음 코드에서 둘 이상의 쓰레드가 하나의 메모리 공간에(하나에 변수에) 접근했을때 발생하는 문제

class Counter{
    int count = 0;  //두 쓰레드에 의해 공유되는 변수

    public void increment(){
        count++;    //첫 번째 쓰레드에 의해 실행되는 문장
    }
    public void decrement(){
        count--;
    }
    public int getCount(){return count;}
}

public class MutualAccess {
    public static void main(String[] args) throws InterruptedException{
        Counter cnt = new Counter();

        Runnable task1 = () -> {
            for(int i = 0; i< 1000; i++)
                cnt.increment();
        };

        Runnable task2 = () -> {
            for(int i = 0; i<1000; i++)
                cnt.decrement();
        };

        Thread t1 = new Thread(task1);
        Thread t2 = new Thread(task2);
        t1.start();
        t2.start();
        t1.join();	//t1이 참조하는 쓰레드의 종료를 기다림
        t2.join();	//t2가 참조하는 쓰레드의 종료를 기다림
        System.out.println(cnt.getCount());
    }
}


-12
36

----------------------------------------------------------------------
위 코드에서 호출하는 join 메소드는 특정 쓰레드의 실행이 완료되기를 기다릴때 호출하는 메소드.

public final void join() throws InterruptedException

위 코드에서 main 쓰레드가 두 쓰레드의 실행이 완료되기를 기다리기 위해서 join 메소드를
호출하였다. 그런데 실행 결과를 보면 예상과 다르다 
첫번째 쓰레드는 increment 두번째 쓰레드는 decrement를 천번 호출하여 결과는 0이어야
하는데 실행할때마다 출력되는 값은 다르다 그리고 이러한 사실을 알 수 있다.

"둘 이상의 쓰레드가 동일한 변수에 접근하는 것은 문제를 일으킬 수 있다."

따라서 둘 이상의 쓰레드가 동일한 메모리 공간에 접근해도 문제가 발생하지 않도록
'동기화'라는 것을 해야한다.

동일한 메모리 공간에 접근하는 것이 왜 문제가 되는가?

동기화 방법에 소개에 앞서 둘 이상의 쓰레드가 하나의 변수 또는 메모리 공간에 함께 접근을 하면
문제가 발생하는 이유를 설명하고자 한다 예를 들어 변수에 저장된 값을 1씩 증가시키는 연산을
두 쓰레드가 동시에 진행한다고 해보자

위 그림에서 주목할 사실이 있다. 값의 증가 방식이다 값의 증가는 코어를 통한 연산이 필요한 작업
따라서 쓰레드는 변수 num에 저장된 값 99를 가져다 코어의 도움을 받아서 이를 100으로 만드는
과정을 거친다 그리고 이 값을 변수 num에 가져다 놓는다 그래야 num의 값이 증가하게 되고
이것이 변수에 저장된 값이 변경되는 방식이다.

만약 thread1도 thread2도 값을 증가시키기 위해 변수 num에 저장된 값 99를 가져갔다.
시차를 두고 가져갈수도 있지만 코어가 여러개니 동시에 가져가는 것도 가능한 상황
그리고 각각 값을 증가시켜 100으로 만들었따 그리고 증가 시킨 값을 다시 변수 num에 
각각 가져다 놓았따. 그러면 변수 num의 값은 얼마인가?
두 쓰레드가 1씩 증가시켰지만 변수 num에는 101이 아닌 100이 저장된다
두 쓰레드 모두 100을 가져다 놓았기 때문이다. 그리고 지금 이 상황이 위 코드에서 수십번 
발생하였다. 이 문제를 해결해보자.

이는 둘 이상의 쓰레드가 동일한 변수에 동시에 접근해서 생긴 문제니
한 순간에 한 쓰레드만 변수에 접근하도록 제한하면 문제는 해결된다.

첫번째 방법 동기화 메소드

앞서 정의한 Counter 클래스의 메소드에 synchronized 선언을 추가하면 동기화가 이뤄진다.

synchronized public void increment(){
	count++;
}

이와 같이 synchronized 선언이 추가되면 이 메소드는 한 쓰레드의 접근만을 허용하게 된다.
예를 들어 이 메소드를 두 쓰레드가 동시에 호출하면 조금이라도 빨리 호출한 쓰레드가 메소드를
실행하게 되고 다른 쓰레드는 대기하고 있다가 먼저 호출한 쓰레드가 실행을 마쳐야 
비로소 메소드를 실행하게 된다.

동기화 진행한 코드

class Counter{
    int count = 0;
    synchronized public void increment(){
        count++;
    }
    synchronized public void decrement(){
        count--;
    }
    public int getCount(){return count;}
}

public class MutualAccessSysncMethod {
    public static void main(String[] args) throws InterruptedException {
        Counter cnt = new Counter();

        Runnable task1 = () -> {
            for(int i = 0; i< 1000; i++)
                cnt.increment();
        };

        Runnable task2 = () -> {
            for(int i = 0; i<1000; i++)
                cnt.decrement();
        };

        Thread t1 = new Thread(task1);
        Thread t2 = new Thread(task2);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(cnt.getCount());
    }
}

0

---------------------------------------------------------------------------

위와 같이 '한 클래스의 두 메소드'synchronized 선언이 되면, 두 메소드는 둘 이상의
쓰레드에 의해 동시에 힐행될 수 없도록 동기화한다.

한 쓰레드가 increment 메소드를 실행하는 중간에 다른 쓰레드가 decrement 메소드를 호출하면
이 쓰레드는 increment의 호출이 완료될 떄 까지 대기하게 된다.

두번째 방법 동기화 블록

앞서 소개한 '동기화 메소드'기반의 동기화는 사용하기는 편하지만 메소드 전체에 동기화를 
걸어야한다는 단점이 있다. 예를 들어 increment 메소드의 내용이 다음과 같다면

synchronized public void increment(){
	count++;	//동기화 필요한 문장
    System.out.println("카운터의 값이 1 증가하였습니다."); //동기화 불필요한 문장
}

동기화가 불필요한 부분을 실행하는 동안에도 다른 쓰레드의 접근을 막는 일이 발생하게 된다.
따라서 이러한 경우에는 다음과 같이 '동기화 블록'이라는 것을 통해 문장 단위로 동기화
선언을 하는 것이 효율적이다.

public void increment(){
	synchronized(this){	//동기화 블록
    	count++;	//동기화 필요한 문장
    }
    System.out.pritnln("카운터의 값이 1 증가하였습니다."); // 동기화 불필요한 문장
}

동기화 블록을 활용한 코드

class Counter{
    int count = 0;
    public void increment(){
        synchronized (this){
            count++;
        }
    }
    public void decrement(){
        synchronized (this){
            count--;
        }
    }
    public int getCount(){return count;}
}

public class MutualAccessSysncMethod {
    public static void main(String[] args) throws InterruptedException {
        Counter cnt = new Counter();

        Runnable task1 = () -> {
            for(int i = 0; i< 1000; i++)
                cnt.increment();
        };

        Runnable task2 = () -> {
            for(int i = 0; i<1000; i++)
                cnt.decrement();
        };

        Thread t1 = new Thread(task1);
        Thread t2 = new Thread(task2);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(cnt.getCount());
    }
}


0

동기화 블록의 선언에 포함된 this의 의미는 다음과 같다

"이 인스턴스의 다른 동기화 블록과 더불어 동기화 하겠다."

쓰레드를 생성하는 좋은 방법

지금 소개하는 이 방법으로 쓰레드를 생성하고 활용하자.

쓰레드의 생성과 소멸은 그 자체로 시스템에 부담을 주는 일이다. 따라서 처리해야 할 일이 
있을때마다 쓰레드를 생성하는것은 성능의 저하로 이어질 수 있는데 그래서 
'쓰레드 풀'이라는 것을 만들고 그 안에 미리 제한된 수의 쓰레드를 두고 이를 재활용하는 
기술을 프로그래머들은 사용해왔다.

위 그림의 쓰레드 풀에는 세개의 쓰레드가 존재한다 그리고 처리해야 할 작업이 있을때 풀에서 
쓰레드를 꺼내 그 작업을 처리하게 만든다. 그리고 작업을 끝낸 쓰레드는 다시 풀로 돌아가 다음
작업을 대기하게 된다. 만약 다음과 같이 두개의 작업이 존재할때

Runnable task1 = () ->{...}
Runnable task2 = () ->{...}

이들 각각에 대해 다음과 같이 쓰레드를 생성하였고 이렇게 생성된 쓰레드는 작업이 끝ㅌ나면
자동으로 소멸되어 리소스 소모가 많았다.

Thread t1 = new Thread(task1);
t1.start();	//이렇게 생성된 쓰레드는 일 끝나면 자동 소멸

Thread t2 = new Thread(task2);
t2.start();	//마찬가지로 소멸


따라서 멀티 쓰레드 프로그래밍에서 쓰레드 풀의 활용은 매우 중요한데 쓰레드 풀의 구현이
간단하지 않다는 문제가 있다. 하지만 다음 코드에서 보이듯이 concurrent 패키지를 활용하면
간단히 쓰레드 풀을 생성할수 있다

위와 관련된 코드

------------------------------------------------------------------------------

public class ExecutorsDemo {
    public static void main(String[] args) {
        Runnable task = () -> { //쓰레드에게 시킬 작업
            int n1 = 10;
            int n2 = 20;
            String name = Thread.currentThread().getName();
            System.out.println(name + ": "+(n1+n2));
        };
        ExecutorService exr = Executors.newSingleThreadExecutor();
        exr.submit(task);   //쓰레드 풀에 작업을 전달

        System.out.println("End : "+Thread.currentThread().getName());
        exr.shutdown();
    }
}


End : main
pool-1-thread-1: 30


---------------------------------------------------------------------------

쓰레드에게 시킬 작업의 마련 방법은 앞서 소개한 방법과 다르지 않다. 다음과 같이 Runnable 
인터페이스를 구현하는 인스턴스를 만들면 된다.

Runnable task = () -> {...}

이어 쓰레드를 생성하지 않고 다음 메소드 호출을 통해서 쓰레드 풀을 생성한다.

ExecutorService exr = Executors.newSingleThreadExecutor(); //쓰레드 풀 생성

이렇게 생성된 쓰레드 풀에 다음과 같이 submit 메소드 호출을 통해 작업을 전달하면
풀에서 대기하고 있던 쓰레드가 이 일을 실행하게 된다. 그리고 작업이 끝나면 해당 쓰레드는
다시 쓰레드 풀로 돌아가서 다음 작업이 전달되기를 기다리게 된다.

exr.submit(task);	//쓰레드 풀에 작업 전달



쓰레드풀 Executors의 메소드

위에서는 newSingleThreadExecutor 메소드의 호출을 통해 쓰레드 풀을 생성했지만
Executors 클래스의 다음 메소드를 통해서 다양한 유형의 쓰레드 풀을 생성할수 있다.

newSingleThreadExecutor	'풀 안에 하나로 쓰레드만 생성하고 유지한다.'
newFixedThreadPool	'풀 안에 인자로 전달된 수의 쓰레드를 생성하고 유지한다'
newCachedThreadPool	'풀 안의 쓰레드의 수를 작업의 수에 맞게 유동적으로 관리한다'


newSingleThreadExecutor가 생성하는 쓰레드 풀 안에는 하나의 쓰레드만 생성해두고
이 쓰레드가 모든 작업을 처리하게 한다. 따라서 하나의 코어를 기준으로 코어의 활용도를
매우 높인 풀이라 할 수 있다. 그러나 이는 마지막에 전달된 작업은 가장 늦게 처리된다는
단점이 있는 풀이다

반면 newCachedTHreadPool이 생성하는 풀은 전달된 작업의 수에 근거하여 쓰레드의
수를 늘리기도 하고 줄이기도 한다. 따라서 가장 효율적으로 쓰레드를 관리하는 것처럼 보이지만
전달된 작업의 수에 비례하여 쓰레드가 생성될 수 있는 관계로 빈번만 쓰레드의 생성과 소멸로
이어질수 있어 주의가 필요하다.

마지막으로 생성된 쓰레드의 풀과 그 안에 존재하는 쓰레드를 소멸하기 위해서는 다음 메소드를
호출해야 한다

void shutdown()

위 메소드가 호출되어도 이미 전달된 작업은 진행이 된다.(주가 작업을 전달받지는 않는다)
전달된 모든 작업이 처리가 되면 풀은 종료가 된다.

하나의 쓰레드 풀에 다수의 작업을 전달하는 코드

public class ExecutorsDemo2 {
    public static void main(String[] args) {
        Runnable task1 = () ->{
            String name = Thread.currentThread().getName();
            System.out.println(name + ": "+(5+7));
        };
        
        Runnable task2 = () -> {
            String name = Thread.currentThread().getName();
            System.out.println(name + ": "+(7-5));
        };

        ExecutorService exr = Executors.newFixedThreadPool(2);
        exr.submit(task1);
        exr.submit(task2);
        exr.submit(()-> {
            String name = Thread.currentThread().getName();
            System.out.println(name + ": " +(5*7));
        });
        exr.shutdown();
    }
}


pool-1-thread-1: 12
pool-1-thread-2: 2
pool-1-thread-1: 35

--------------------------------------------------------------------

위 코드에서 다음 문장을 통해 두 개의 쓰레드가 존재하는 쓰레드 풀을 생성하였다

ExecutorService exr = Executors.newFixedThreadPool(2);

이 풀을 대상으로 세 개의 작업을 전달했는데 세번째 작업의 전달 방식은 다음과 같다

exr.submit(()-> {
            String name = Thread.currentThread().getName();
            System.out.println(name + ": " +(5*7));
        });
        
반복해서 시킬일이 아니라면 이렇듯 submit 메소드의 인자 전달 위치에 람다식을 작성하는
것도 괜찮은 선택.

Callable & Future

지금까지 작업의 형태는 Runnable 인터페이스를 기반으로 한다.

Runnable task1 = () -> {
	...
};

이 경우 Runnable에 위치한 추상 메소드 run의 반환형이 void이기 때문에 작업의
결과를 return문을 통해 반환하는것은 불가능하다 

그래서 다음 인터페이스를 기반으로 작업을 구성하면 작업의 끝에서 값을 반환하는 것이 가능하다.
특히 반환형도 결정할 수 있다.

@FunctionalIn
public interface Callable(V){
	V call() throws Exception;
}

위와 관련된 코드

public class CallableDemo {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> task = () -> {
            int sum = 0;
            for(int i = 0; i < 10; i++)
                sum += i;
            return sum;
        };

        ExecutorService exr = Executors.newSingleThreadExecutor();
        Future<Integer> fur = exr.submit(task);

        Integer r = fur.get();
        System.out.println("result : "+r);
        exr.shutdown();
    }
}


result : 45

위 코드에서 값의 반환을 위한 작업을 다음과 같이 마련하였다. 반환하는 값이 int형 값이므로
타입 인자로 Integer를 전달하였다.

Callable<Integer> task = () -> {
            int sum = 0;
            for(int i = 0; i < 10; i++)
                sum += i;
            return sum;
        };
        
그리고 이 작업을 쓰레드 풀에 다음과 같이 전달하였다. 이 경우에는 메소드의 반환 값을 다음
과 같이 Future<V>형 참조 변수에 저장해야 한다.
(Future 타입 인자는 Callable의 타입 인자와 일치시켜야 한다.)

Future<Integer> fur = exr.submit(task);

위의 참조변수를 통해 다음과 같이 쓰레드가 실행한 메소드의 반환 값을 얻는다.

Integer r = fur.get();	//쓰레드의 반환 값 획득

만약에 위의 메소드를 호출한 시점에 쓰레드가 작업을 끝내지 않은 상태라면 해당 쓰레드가
값을 반환하고 작업을 끝낼때까지 대기하게 된다.

synchronized 를 대신하는 ReentrantLock

자바5에서는 동기화 블록과 동기화 메소드를 대신할 수 있는 ReentrantLock 클래스를 제공하였다.

class Myclass{
	ReentrantLock criticObj = new ReentrantLock();
    	void myMethod(int arg){ 
        	criticObj.lock();	//문을 잠근다
            	...	//한 쓰레드에 의해서만 실행되는 영역
            	criticObj.unlock();	//문을 연다.
        }
}


위 코드에서 한 쓰레드가 lock 메소드를 호출하고 이어 다음 문장을 실행하기 시작한 상태에서
다른 쓰레드가 lock 메소드를 호출하면 이 쓰레드는 lock 메소드 호출을 반환하지 않고 그 
자리에서 대기하게 된다. 먼저 lock 메소드를 호출한 쓰레드가 unlock 메소드를 호출할떄까지
대기하게 된다.
따라서 lock 메소드의 호출 문장과 unlock 메소드의 호출 문장 사이는 하나의 쓰레드만이
실행할 수 있는 영역이 된다
그런데 lock 메소드를 호출한 쓰레드가 unlock 메소드를 호출하지 않는 코드상의 실수가 
발생할수 있으니 예외처리를 하는것이 안정적이다.

class Myclass{
	ReentrantLock criticObj = new ReentrantLock();
    	void myMethod(int arg){ 
        	criticObj.lock();	//문을 잠근다
            try{
            	...	//한 쓰레드에 의해서만 실행되는 영역
                } finally{
            	criticObj.unlock();	//문을 연다.
                }
        }
}

위를 활용한 코드


class Counter{
    int count = 0;
    ReentrantLock criticObj = new ReentrantLock();

    public void increment(){
        criticObj.lock();
        try{
            count++;
        }finally {
            criticObj.unlock();
        }
    }
    public void decrement(){
        criticObj.lock();
        try{
            count--;
        }finally {
            criticObj.unlock();
        }
    }
    public int getCount(){return count;}
}
public class MutualAccessReentrantLock {
    public static void main(String[] args) throws InterruptedException {
        Counter cnt = new Counter();
        Runnable task1 = () -> {
            for(int i = 0; i < 1000; i++)
                cnt.increment();
        };

        Runnable task2 = () ->{
            for(int i=0; i < 1000; i++)
                cnt.decrement();
        };

        ExecutorService exr = Executors.newFixedThreadPool(2);
        exr.submit(task1);
        exr.submit(task2);

        exr.shutdown();
        exr.awaitTermination(100, TimeUnit.SECONDS);
        System.out.println(cnt.getCount());
    }
}



0


----------------------------------------------------------------------

위 코드에서 increment 메소드와 decrement 메소드에 진입해서 lock 메소드를 호출하고
또 두 메소드를 빠져나갈때 unlock 메소드를 호출하도록 작성하였다. 따라서 두 쓰레드가 동시에
두 메소드를 실행하는 일은 발생하지 않았다

exr.awaitTermination(100, TimeUnit.SECONDS);
-> 쓰레드 풀에 전달된 작업이 끝나기를 100초간 기다린다.

생각과 달리 shudown 메소드는 바로 반환이 된다. 즉 쓰레드에 풀에 전달된 작업이 마무리되면
풀을 폐쇄하라고 명령을 할 뿐 기다려 주지는 않는다. 그래서 쓰레드 풀에 전달된 작업이
최종 결과를 확인 하기 위해서는 위 문장이 필요하다 
위 문장이 실행되면 awaitTermination 메소드의 호출은 블로킹 상태에(반환하지 않은)놓이게
되어 다음 두 가지중 한가지 상황에 이르러야 메소드를 빠져나오게 된다.

- 쓰레드 풀에 전달된 모든 작업이 완료되었다.
- 작업이 완료되지는 않았찌만 초를 기준으로 100을 세었다.

물론 대기시간은 얼마든지 수정 가능하다.

컬렉션 인스턴스 동기화

동기화는 그 특성상 어쩔수 없이 성능의 저하를 수반한다 따라서 불필요하게 동기화를 진행하지 않도록
주의해야 한다. 이런 이유로 프레임워크의 클래스 대부분도 동기화 처리가 되어있지 않다.
따라서 쓰레드의 동시 접근에 안전하지 않다 대신에 Collections의 다음 메소드들을
통한 동기화 방법을 제공하고 있다.

public static<T> Set<T> synchronizedSet(Set<T> s)
public static<T> List<T> synchronizedList(List<T> list)
public static<K,V> Map<K,V> synchronizedMap(Map<K,V> m)
public static <T> Colleciont<T> synchronizedCollection(Collection<T> c)

동기화 방법은 간단하다 ArrayList<String> 인스턴스를 쓰레드 동시 접근에 안전한 상태가
되게 하려면 다음과 같이 문장을 구성하면 된다.

List<STring> lst = Collections.synchronizedList(new ArrayList<String>());

위와 관련된 코드

-------------------------------------------------------------------------

public class SyncArrayList {
    public static void main(String[] args) throws InterruptedException {
        List<Integer> lst = Collections.synchronizedList(new ArrayList<Integer>());
        for(int i= 0;i< 16; i++)
            lst.add(i);
        System.out.println(lst);

        Runnable task = () -> {
            ListIterator<Integer> itr = lst.listIterator();
            while(itr.hasNext())
                itr.set(itr.next() + 1);
        };

        ExecutorService exr = Executors.newFixedThreadPool(3);
        exr.submit(task);
        exr.submit(task);
        exr.submit(task);

        exr.shutdown();
        exr.awaitTermination(100, TimeUnit.SECONDS);
        System.out.println(lst);
    }
}


[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
[1, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]

--------------------------------------------------------------------------

동기화 처리된 컬렉션 인스턴스에 다음과 같이 0부터 15까지 차례로 저장을 하였다.
List<Integer> lst = Collections.synchronizedList(new ArrayList<Integer>());
        for(int i= 0;i< 16; i++)
            lst.add(i);
            
그리고 다음과 같이 컬렉션 인스턴스에 저장된 값을 1씩 증가시키는 작업을 마련하였다.

Runnable task = () -> {
            ListIterator<Integer> itr = lst.listIterator();
            while(itr.hasNext())
                itr.set(itr.next() + 1);
        };
        
 이렇게 마련된 작업을 쓰레드 풀에 총 3회 전달했으니 컬렉션 인스턴스에 저장된 값은 모두 3씩
 증가한 상태여야 한다. 그러나 실행 결과를 보면 이와 다름을 알 수 있다. 왜 그럴까?
 우선 컬렉션 인스턴스에 자체에 대한 동기화에는 문제가 없다
 
 문제는 반복자이다. 컬렉션 인스턴스가 동기화 되었다고 해도 이를 기반으로 생성된 반복자까지
 동기화가 이뤄지는것은 아니다. 따라서 반복자를 통해 접근할 때에는 반복자고 동기화 해야한다.
 
 ---------------------------------------------------------------------------
 
 반복자도 동기화한 코드
 
 Runnable task = () -> {
            synchronized (lst) {    //이 영역 실행시 lst에 다른 쓰레드 접근 불가
                ListIterator<Integer> itr = lst.listIterator();
                while (itr.hasNext())
                    itr.set(itr.next() + 1);
            }
        };
        
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
[3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]

위 코드에서 동기화 블록이 갖는 의미는 다음과 같다

"동기화 블록의 내부를 실행할때 lst에 다른 쓰레드의 접근을 허용하지 않는다"

그 전에 synchronized 를 대신할 수 있는 ReentrantLock 기반의 동기화 방법을 말했는데
그렇다고 해서 키워드 synchronized가 불필요해진 것은 아니다.
그리고 지금까지 ArrayList<E>를 기반으로 동기화 설명했지만
다른 컬렉션 인스턴스이 동기화 방법도 이와 마찬가지이다.
profile
반갑습니다

0개의 댓글