프론트엔드

Java Multi Process Programming

CyberI 2015. 7. 30. 14:32



오늘날 H/W의 급속한 발전으로 기본적으로 multi-core 환경에서 대부분의 server side 개발자가 개발을 하고 있습니다. 하지만 일반적으로 이러한 멀티코어 환경을 잘 이해하지 못한 상태에서 일반적인 코딩을 하여 성능이점을 누리지 못하거나, 생각하지 못한 결과 값을 얻는 경우도 있습니다.

오늘은 Java 의  Multi process programming에 대해서 설명을 하려고 합니다. 사실 multi process programming을 완전히 이해하기 위해서는 Hardware상의 CPU와 Memory 간의 아키텍처에 대한 이해가 필요합니다.

다양한 CPU 아키텍처가 있겠지만 일반적인 Multi-core CPU는 다음과 같은 Hardware Architecture를 가지고 있습니다.

Multi-core 

 Multi - CPU (NUMA)

 

 

L1 Cache는 CPU Core에 직접적으로 연결되어 1ns 단위의 빠른 속도로 메모리를 읽어 들일 수 있습니다. 단 L1 Cache는 Core에 해당되는 작업(process) 대기를 위한 메모리가 저장되는 영역으로 사이즈가 작고 다른 Core에서 처리하기 위한 데이터를 읽어 들이지는 못합니다.

L2 Cache는 CPU Core간에 데이터 공유가 필요한 경우 사용되는 영역으로 보통 3ns 단위의 데이터 access가 수행되고 RAM와 memory controller에 연결되어 RAM에서 자주 사용되는 영역은 L2 Cache로 데이터를 올려서 처리속도를 높여주는 역할을 합니다.

우리가 알고 있는 메모리(RAM)도 사실 CPU 입장에서는 매우 느린 저장장치 이므로 되도록 L2 Cache나 L1 Cache를 활용한 코딩을 할 수 있다면 성능을 극대화 할 수 있습니다.


Atomicity

우리말로 하면 원자성이라는 의미로 해석할 수 있으며 더 이상 나눌 수 없는 가장 작은 단위의 정보 또는 행위를 의미한다고 보면 됩니다.

우선 다음 그림에서 int a=123; 라는 정의는 CPU 입장에서 atomicity를 가지는 작업입니다. 타입정의 및 주소지정 등 여러 내부적인 작업이 있을 수 있겠지만 프로그램 입장에서는 a라는 변수에 123 이라는 값을 지정하는 것은 CPU의 1 cycle로 처리되는 작업입니다.

하지만 long a=123L; 은 원자성을 가지는 작업일까요? 정답은 CPU에 따라 다릅니다. 64bit CPU에서는 문제가 없겠지만 우선 아래 그림과 같이 32 bit CPU 환경에서는 한번에 처리할 수 있는 데이터 최소 단위가 32bit 단위입니다. 하지만 long 타입은 일반적으로 64bit (8byte) 입니다. 따라서 1 cycle 로 처리하지 못하고 long 타입을 처리하기 위해서는 CPU 입장에서 2cycle이 필요합니다.

따라서 만약에 같은 변수의 값을 여러 CPU core에서 read/write 하게 된다면 아래 그림과 같이 잘못된 값으로 설정될 가능성이 있습니다.

사실 CPU clock 속도가 엄청나게 빨라서 read cycle 중간에 다른 CPU Core가 값을 더럽히는 일이 극히 드물지만 이러한 read/write 작업이 100% 신뢰할 수 없다는 것은 생명을 다루거나 mission critical 한 업무를 처리한 프로그램에서는 큰 재앙이 될 수 도 있습니다.

  Not thread safe

 thread safe

 

 

따라서 다음과 같이 volatile 접근 제한자 키워드를 사용해야 합니다.  volatile 말그대로 변덕스러운 변수이니 JVM에게 조심하게 처리하고 알려주는 것입니다.  volatile 를 선언하면 원자성을 보장하면 multi process 환경에서도 안전하게 사용할 수 있습니다.

다음은 연산자에 대한 Atomicity 입니다.

a=a+1 작업이 코딩으로는 한 줄이지만 내부적으로는 1) a라는 값을 읽고 2) shift 연산으로 값을 증가시키고 3) 증가된 값을 재할당 이라는 3가지 과정으로 거치게 되어 있습니다.  따라서 thread 환경에서 a 라는 값을 다른 thread에서 read/write 하는 상황이라면 예상치 않은 상황이 발생할 수 있습니다.

 Not thread safe

 thread safe

 


 


이런 경우는 java.util.concurrent.atomic package에 있는 primitive 타입 값들에 대한 연산작업에 대하 원자성을 보장해줄 수있는 class 를 사용해야 합니다.

False Sharing

마지막으로 이전에 설명한 원자성 처리 때문에 오류가 나는 것은 아니지만 multi core 환경에서 잘못된 sharing 처리 때문에 성능저하가 발생되는 상황을 설명하려 합니다.


가령 아래와 같이 2가지 타입의 객체선언이 있다면 첫번 class는 객체화 되어 실제 RAM에 load되는 사이즈는 16byte 가 됩니다.   (Markword+Class Addres) 8 byte + long value (8byte) = 16byte


만약 이런 경우 아래 그림처럼 RAM에 2개의 객체가 순차적으로 RAM에 load되고,L2 Cache에 동시에 첫번째 객체와 두번째 객체가 로드됩니다. 왜냐 RAM에서 L2 Cache 로 데이터롤 load 할때 Cache Line 단위로 load 하는데 이 데이터 단위가 일반적으로 64byte 단위기 때문입니다. 즉 두번째 객체값을 직접적으로 쓰지 않는다고 해도 CPU 가 미리 인접한 메모리 내용을 load 해서 이런 현상이 발생합니다.



이렇게 load가 되면 multi core 환경에서 cache lince 단위로 core의 각 L1 캐쉬 데이터를 공유하기 때문에 성능저하가 일어납니다. 따라서 cache line 단위  즉 64byte 단위로 객체 사이즈가 설정되면 가장 최적의 성능을 낼 수 있습니다.


아래 소스는 객체 사이즈에 따라 어떠한 성능차이가 나타나는즉 확인해 볼수 있는 소스입니다.

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
public final class FalseSharingTest implements Runnable {
 
    public final static long LOOP_COUNT = 10L * 1000L * 1000L;
    private final int arrayIndex;
 
    private static TestLong[] longs;
 
    public FalseSharingTest(final int arrayIndex) {
        this.arrayIndex = arrayIndex;
    }
 
    public static void main(final String[] args) throws Exception {
        
        for(int i=0;i<=12;i++) {
            System.gc();
            long start = System.nanoTime();
            runTest(i,0);
            System.out.println(i+" test1 = " + (System.nanoTime() - start));
            
            System.gc();
            start = System.nanoTime();
            runTest(i,1);
            System.out.println(i+" test2 = " + (System.nanoTime() - start));
        }
    }
 
    private static void runTest(int threadNum, int type) throws InterruptedException {
 
        longs = new TestLong[threadNum];
 
        if (type == 0) {
            for (int i = 0; i < longs.length; i++) {
                longs[i] = new TestLong();
            }
        } else {
            for (int i = 0; i < longs.length; i++) {
                longs[i] = new VolatileLong();
            }
        }
 
        Thread[] threads = new Thread[threadNum];
 
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new FalseSharingTest(i));
        }
 
        for (Thread t : threads) {
            t.start();
        }
 
        for (Thread t : threads) {
            t.join();
        }
    }
 
    public void run() {
        long i = LOOP_COUNT + 1;
        while (0 != --i) {
            longs[arrayIndex].value = i;
        }
    }
 
    public static class TestLong {
        public volatile long value = 0L;
 
    }
 
    public static class VolatileLong extends TestLong {
        public long p1, p2, p3, p4, p5, p6;
    }
 
}
cs

Cache line 사이즈를 고려하지 않은 TestLong class와 려된 VolatileLong class를 각각 여러 thread에서 값을 참조하는 프로그램을 통해 성능측정을 해보았습니다.

webponent CHART로 생성한 결과 그래프에서도 보듯이 False Sharing 방지된 VolatileLong class를 access하는 처리속도가 (녹색라인) thread에 수 증가에 따른 동시 작업이 많을 수록 성능저하가 적게 일어남을 알 수 있습니다.