[자바 병렬 프로그래밍]
- 병렬 프로그래밍이라는 것은, 개발자가 소스코드를 작성할 때, 위에서 아래로 차례대로 흐르는 방식의 코드 흐름을 작성하는 것만이 아닌,(일반적으로 hello world를 찍을 때도 사용되는 것이 바로 이 일직선의 코드 흐름입니다.) 한 코드 내에, '동시적'으로 실행되는 코드와 로직의 흐름을 만들어 내는 것입니다.
- 여기선 자바를 중점으로 한 병렬 프로그램에 대해 정리할 것입니다. 어렵지 않으니 차근차근 알아보죠.
먼저, 필요 지식에 대해 설명하겠습니다.
(Thread)
- 한 OS에서 동시에 실행되는 프로그램을 프로세스라는 단위로 부릅니다.
멀티 프로세싱이라는 것은, OS가 작동 시키는 프로세스의 갯수가 복수개라는 뜻이죠.
(만일 윈도우 같은 OS를 사용할 때, 한번에 하나의 작업만 가능하다 해봅시다. 그림판이라도 하나 띄워놓는다면 인터넷을 못하고, 인터넷을 하면 시계가 안 돌아가는 등의 문제가 생기겠죠.)
- 쓰레드는 한 프로세스 안에 생성되는 실행 흐름을 말합니다.
즉, 멀티 쓰레드는, 한 프로세스 안에 여러개의 쓰레드가 존재하는 것을 말하죠.
- 쓰레드는 프로세싱과 동일한 개념을 지닙니다.
다만, 프로세스 안에 존재하는 병렬적인 구성이죠.
- 원리를 알아봅시다.
일반적으로 작성되는 하나의 흐름을 지닌 프로세스는 단일 쓰레드 프로세스입니다.
코드를 해석함에 있어서, 한 코드를 위에서 부터 아래로 일직선으로 처리하기에, 만일 중간에 정보를 처리하기 위해 기다리는 부분이 있다면 아래 코드는 실행되지 않고 대기하는 상태가 되죠.
예를 들어, Scanner 객체를 이용하여, 사용자의 입력을 대기받는 기능으로 인해, 코드의 흐름이 정체된 상태라면, 그 아래로는 코드가 실행되지 않고, 프로세스가 대기중인 상태가 됩니다.
만일, 대기를 하려는 동시에 카운트다운을 하고 싶어도 단일 쓰레드로는 불가능합니다.
멀티 쓰레드는, 여러 쓰레드에 있어서 코드를 분할하여 사용됩니다.
말 그대로, 그 부분은 코드가 분할되어서 '병렬적'으로 동작하기에, 우리는 프로그램의 동시성을 얻을수 있게 됩니다.
구체적인 원리에 대해서는, 일단 실질적으로 작업을 처리하는 CPU 단위에서 본다면, 아예 처리하는 CPU 모듈이 복수개인, 멀티 프로세싱 CPU 코어에 의해서 작업이 물리적으로 병행될수 있고,(개인 PC에서 거의 표준으로 나오는 쿼드코어 CPU의 경우엔, CPU 연산을 처리하는 부분이 4개라는 뜻입니다. 즉, CPU 자체로도 4개의 작업을 동시에 실행할수 있죠.)
그것이 아니라, 실질적인 연산 코어가 부족한데도, 더 많은 작업을 병행하기 위해서는
'시분할 시스템'이라는 기법을 사용하여,
CPU의 사용 시간을 각 쓰레드에 나누어 줍니다.
- 시분할 시스템 :
운영체제를 공부하시면 나오는 내용으로, 각 쓰레드에 CPU 자원을 나눠주는 방법을 말하는 것입니다.
현실에도, 많은 사람들이 부족한 자원을 나눠 쓸때, 몇 시 부터 몇시까지는 A가, 그리고 다음은 B가...
이런 식으로 사용 시간을 분할하는 것처럼,
컴퓨터 자원의 사용에 대하여, OS가 각 프로세스에게 '효율적인' 분배를 해주는 형식으로, 번갈아가며 사용합니다.
성능이 최대한 떨어지지 않는 형태로,
최대한 빠르게 시분할을 한다면,
분할받는 각 프로세스는, 빠르게 각 자원을 사용하면서 스위칭 되며, 마치 '동시'에 작업을 진행하는 것처럼 보이게 될 것입니다.
(자바 병렬 처리 구현)
- 자바에서 병렬 처리 구현을 하는 것은 쉽습니다.
자바는 클래스 단위로 프로그래밍을 나누므로, 이 클래스 단위로 쓰레드를 나누어 준다고 명시하면 됩니다.
- 원하는 클래스의 Thread 상속법
public class Thread1 extends Thread{
@Override
public void run() { // run()메소드를 오버라이딩
//병렬적으로 처리할 로직 작성
}
}
: 우리가 쓰레드로 나누고 싶은 클래스를 Thread 클래스를 상속받아, 그 안에 선언된(정확히 말하자면, Thread 클래스가 implements 한, Runnable이 선언한) run 메소드 안에, 우리가 로직을 재정의 해주면 됩니다.
바로 이 쓰레드 클래스를 객체화 하여, 사용하면, 병렬적으로 처리되는 로직을 run() 메소드 안에 넣어주면 되는 것이죠.
사용법으론,
public static void main(String [] args){
Thread t1 = new Thread1();
t1.start();
}
이렇게 간단하게, Thread 객체를 생성하고,
그 안에 미리 병렬적으로 동작 하도록 만들어진 start 메소드를 실행시키면,
우리가 run 안에 작동시켰던 로직이 main 쓰레드와는 별개의 쓰레드로, 동시에 실행되게 될 것입니다.
만일 우리가 run 메소드 안에,
while(true)
와 같은 반복문을 사용하여도, main 쓰레드는, t1.start() 부분에서 멈추지 않고,
해당 반복문은 반복문대로, main 쓰레드는 main 대로 실행이 될것입니다.
- 쓰레드 작성 다른 방식
: 위의 방법도 좋지만, 자바에서는 extends를 하나 밖에 사용하지 못하죠?
그래서 Thread 클래스를 직접 extends 하는 것은 별로 좋지 않습니다.
대신하여, 사용되는 방법이 있습니다.
public class Thread2 implements Runnable{
@Override
public void run() { // run()메소드를 오버라이딩
//병렬적으로 처리할 로직 작성
}
}
위와 같이, Runnable 인터페이스를 사용하면, Thread 클래스와 같이, run 메소드를 작성할수 있습니다.
그리고 다른 상속을 사용할 때에도 불편함이 없죠.
작성은 Thread와 같이 하고,
사용시에는,
public static void main(String [] args){
Thread t2 = new Thread(new Thread2());
t2.start();
}
위와 같은 방식으로, Thread 생성자의 매개변수로, 내가 작성한 클래스를 객체화 하여 넣어주면 됩니다.
- 쓰레드 작성 간편 방식
: 람다식을 이용하여, 로직 작성과 동시에 실행을 시킬수도 있습니다.
public static void main(String [] args){
Thread thread3 = new Thread( () -> {
for (int i = 0; i < 10; i++) {
System.out.println(i);
}
});
thread3.start();
}
Thread에 쓰이는 매개변수가 요구하는 메소드는 한개이기 때문에, 위와 같이 람다식을 이용하여, 즉석에서 간편하게 run 메소드를 작성해서 사용할수 있습니다.
(쓰레드간 우선순위 결정)
public static void main(String [] args){
Thread t1 = new Thread(new Thread1());
Thread t2 = new Thread(new Thread2());
Thread t3 = new Thread(new Thread3());
t1.setPriority(t1.MAX_PRIORITY);
t2.setPriority(t2.NORM_PRIORITY);
t3.setPriority(t3.MIN_PRIORITY);
t1.start();
t2.start();
t3.start();
}
: 쓰레드는 시분할 시스템으로, 어느 쓰레드가 먼저 실행될지에 대한 기준이 희미합니다.
기본적으로는 NORM_PRIORITY로, 같은 우선순위 끼리 같은 확률로 실행될 자격을 얻는데,
위와 같이 setPriority를 사용하면, 각 쓰레드별로 우선순위를 지정해줄수 있습니다.
(주의점)
- 위까지 하여, 간단하게, 자바에서 쓰레드를 나누는 방법을 알아보았습니다.
이제 한 코드 안에 동시적으로 실행되는 구간을 만들어 낼수 있죠.
그러나 병렬처리는 여러가지 주의점이 있습니다.
- 쓰레드간 정보 : 통신이라 하니, 멀리 있는 사람에게 전화를 거는 것 같은 행위를 떠올릴 수도 있을텐데, 그냥 각 쓰레드 간에 정보를 교환하는 것을 말합니다.
public class Thread1 implements Runnable{
@Override
public void run() { // run()메소드를 오버라이딩
//병렬적으로 처리할 로직 작성
}
}
public class Thread2 implements Runnable{
@Override
public void run() { // run()메소드를 오버라이딩
//병렬적으로 처리할 로직 작성
}
}
위와 같이, 두 쓰레드 클래스가 있다고 해봅시다.
그렇다면, 각 클래스는 서로 다른 변수 공간과 실행 공간을 가지고 있습니다.
이 둘 사이에는 각자 다른 스코프를 가지기에, 서로 침범할수 없죠.
만일 t1과 t2가 동일한 변수를 가리키려면 어떻게 해야 할까요?
값만을 원한다면,
각 쓰레드 흐름을 가지게 되는 클래스의 매개변수로, 값을 받아와, 지역변수로 사용하는 방식이 있겠지만,(run 메소드는 오버로딩이 안되니, 생성자 매개변수를 사용해 멤버변수로 만듭니다.)
만일 동일한 메모리 공간을 참조하고 싶다면,
값을 클래스로 감싸서, 참조형 변수로 만들어주는 기법인 DTO를 사용해도 되고,
프로그램이 실행될 때부터 끝날 때까지 메모리에 올라가 있는 static 변수를 사용하여,
System.out.println(Thread3.intValue);
이런 식으로 값을 사용해도 됩니다.
- 병렬 프로그래밍에서 주의해야 할 점은, 바로 이런 공유 영역에 대한 처리입니다.
- 공유 변수 : static을 이용하여, 쓰레드 클래스 내에서, Thread3.intValue; 이런 식으로 값을 사용하게 되면, 편하게 해당 값에 접근 및 사용이 가능하지만, 문제가 생길 여지가 있습니다.
만이 Thread1이, 지속적으로 MainClass.intValue를 print하고,
Thread2가, 지속적으로 MainClass.intValue를 1씩 늘린다고 합시다.
둘은, 소위 말하는 동기화라는 것이 되어있지 않습니다.
그 말은,
Thread1이 MainClass.intValue의 값이 0을 출력하고,
다음에 Thread2가 MainClass.intValue의 값을 1 증가시키는 방식으로, 순차로 실행되는 것이 아니라,
어떤 식으로 시분할 되어 병렬적으로 처리될지에 대한 기준이 없기 때문에, 가끔은 같은 값을 2,3번 print 하기도 하고,
print 되지도 않은 값을 증가시키기도 합니다.
뭐, 이런 기능이야 병렬처리에서 별로 문제될 것이 없고, 시간 같은 것을 표시할 때, 일부러 이런 식으로 비동기 방식을 택하기도 하는데,
만일 은행 업무 처리 및, 쓰레드 간에도 서로 동기화된 프로세스를 실행해야 하는 경우가 있습니다.
- 공유 메서드 : 마찬가지로 static을 이용하여 작성한 메서드 역시 프로그램 최초 기동시 메모리에 올라가게 되는데, 이 역시 여러 쓰레드에서 동시에 사용될수 있습니다.
그 동시성으로 인한 충돌 같은 문제가 일어나는 것을 방지하고 싶을 때도 있겠죠.
- 공유 자원 : 메모리에 대한 접근이나, CPU 와 같은 하드웨어 자원의 사용에 대하여, 공유되는 부분이 있습니다.
이 역시 여러 쓰레드가 동시에 요청을 하게 되면 여러모로 문제가 발생할수 있습니다.
- 공유 변수 해결 : 여기서는 파생 지식까지 자세히 말씀드리지는 않겠습니다.
병렬 프로그래밍의 부분은 따로 자세히 정리할텐데,
키워드만 말해두자면, 세마포어와 뮤텍스라는 것이 있습니다.
그것을 사용하여 공유 공간에 대한 관리가 가능한데, 자바에서는 아주 간단하게 쓰레드간 공유 영역에 대한 동기화를 해주는 방법이 있습니다.
[synchronized 키워드]
- 아주 간단합니다.
여러 쓰레드가 동시에 실행하는 것을 막고자 하는 메소드나 블록에 synchronized 키워드를 붙이면 됩니다.
- synchronized 메소드
public static synchronized add(){
}
: static이고 아니고 상관이 없습니다.
여러 스레드가 해당 메소드를 사용하고자 할 때, 순차적으로 접속할수 있도록 동시 접속을 막아놓는 키워드 입니다.
그냥 synchronized가 붙은 메소드는, 한 번에 하나의 스레드만이 사용 가능하다고 알아두세요.
그러니 싱글 스레드 상태에서 붙여놓아도 별 상관이야 없겠죠.
만약 synchronized를 붙이지 않은 메소드를 쓰레드가 공유하여 사용한다면,
쓰레드 안의 상태, 즉 변수 값이 도중에 바뀌어 버릴수도 있습니다.
Thread1은 add 안의 변수값을 1로 변형시키고, 마지막에 출력하려 했는데,
나중에 들어온 Thread2가 이 변수값을 2로 변형시킨다면, Thread1이 출력하는 값은, 의도한 1이 아닌 2가 되는 것이죠.
그렇기에 이렇게 synchronized를 사용하면 한 쓰레드가 해당 메소드를 사용하는 도중에, 다른 메소드가 이곳에 침범할수 없게 됩니다.
주의할 점은, synchronized 메소드의 경우는, 해당 메소드 뿐만 아니라, 해당 메소드를 가지고 있는 '객체'에도 Lock이 걸린다는 점을 명심하세요.
그러니까, synchronized 메소드가 사용하는 클래스의 멤버변수 역시 다른 스레드가 침범하지 못한다는 뜻이고, 해당 메소드를 품고 있는 객체 자체에 lock을 거는 것입니다.
- synchronized 블록
: 위와 같이 내가 원하는 부분만이 아니라 객체 자체에 lock이 걸리게 하고 싶지는 않은 경우가 있을수 있습니다.
어느 부위에만 synchronized를 적용하는 방법이 바로 synchronized 블록입니다.
public void add(int val) {
/* * Code for synchronization is not needed * */
synchronized(this){
//동기화 할 부분
}
}
}
위와 같이, 클래스 내부 구역 어디든, synchronized 키워드를 위와 같이 사용하여 블록을 형성하면, 해당 블록 내부 구역은, 쓰레드간 동기화가 되는 것입니다.
- synchronized 블록의 매개변수
: 위와 같이, synchronized 블록을 이용하면, 매개변수를 넣어줘야 합니다.
이 매개변수는 Object 타입입니다.
this도 Object이므로 상관 없죠.
그런데, 이 매개변수가 어떤 일을 할까요?
간단히, 이 파라미터로 들어오는 객체의 공간을 공유한다고 생각해 주세요.
해당 블록 안에 사용되는 자원을 품은 객체를 넣어주면 됩니다.
class B
{
public void meth()
{
for(int i=0;i<3;i++)
{
System.out.println(Thread.currentThread()+" " + i);
}
}
}
class A implements Runnable
{
B obj;
A(B ob)
{
obj=ob;
}
public void run() //Entry point of the thread.
{
//Synchronized block
synchronized(obj) //synchronizing the object of B class
{
obj.meth(); //call to meth() is synchronized
}
}
public static void main(String... ar)
{
B ob= new B();
A thread1= new A(ob);
A thread2= new A(ob);
Thread t1= new Thread(thread1, "Thread1");
Thread t2= new Thread(thread2, "Thread2"); //Calling Thread's constructor & passing the object
//of A class that implemented Runnable interface
//& the name of new thread.
t1.start();
t2.start();
}
}
: 위와 같이, B 클래스가 synchronized 블록의 매개변수로 들어가며,
해당 객체가 synchronized 되는 것이죠.
(기타)
- 현재 진행중인 쓰레드 정보 가져오기
: Thread 객체의 메소드로, 사용중인 쓰레드 정보를 가져올수 있습니다.
Thread.currentThread().getName()
- 메인 쓰레드도 결국은 쓰레드 입니다.
각 쓰레드는, 일단 시작되면 독립된 시행을 보내는데, 이는 메인 쓰레드가 종료되더라도 독립 쓰레드가 종료되지 않는 것을 뜻합니다.
만일 메인 쓰레드가 종료됨과 동시에, 포함되는 각 쓰레드들을 모두 종료시키기 위해서는,
public static void main(String [] args){
Thread t1 = new Thread(() -> {while(true){}});
t1.start();
t1.setDaemon(true);
}
위와 같이, .setDaemon(true); 메서드를 실행시켜주면 됩니다.
.setDaemon(true); 메서드는, 해당 스레드를 데몬 스레드로 만들어주는 메서드입니다.
다시한번 데몬 스레드에 대해서 설명하자면, 다른 비 데몬 스레드에 기생하여, 해당 비 데몬 스레드가 종료됨과 동시에 종료되는 스레드를 말하죠.
[volatile 키워드]
- 자바 volatile 키워드는 자바 코드의 변수를 '메인 메모리에' 저장 및 활용 할 것을 명시하기 위해 쓰입니다.
정확히 말해서, 모든 volatile 변수는 컴퓨터의 메인 메모리로부터 읽히고, volatile 변수에 대한 쓰기 작업은 메인 메모리로 직접 이루어집니다. ( == CPU 캐시가 쓰이지 않습니다.)
- 자바 코드 최적화에 대해 논해보죠.
만약 우리가 int a = 10; 이라고 변수를 만들었다고 합시다.
어느 프로그램이라도 그렇듯, 하드웨어에 저장된 정보는, 메모리 공간 위에 올라가고, 메모리 공간 위에 저장된 정보 역시, 요청을 받으면, CPU 내부의 작은 저장 공간인 레지스터로 이동하게 됩니다.
하드웨어에서 메모리로 정보가 올라가는 데에도 시간이 많이 걸리지만,
메모리에서 CPU로 정보가 넘어가는데 드는 시간도 있죠.
그렇기에 CPU에서는 캐싱이라는 기법을 사용합니다.
잘 쓰일것 같은 정보들을 CPU 내부 메모리에 저장해두고 사용하는 것으로,
이렇게 하면, 메모리에서 값을 받아오는데 들이는 시간을 줄일수 있죠.
자바의 바이트 코드는 JVM에 의해서 이러한 최적화 과정을 거칩니다.
-하지만 오히려 이러한 최적화가 방해가 되는 경우가 있습니다.
자, 한번 봅시다.
한 프로그램에서 변수 a를 생성했습니다.
이것을 그냥 사용시, 처음 한번은, 메모리에서 CPU로 값이 이동할 것입니다.
그리고 특별히 일이 없을 때에는 이 변수의 값이 사용되지 않을 것이기에 캐시 메모리에서 대기를 합니다.
그런데, 메인 쓰레드와는 또 다른 새로운 쓰레드가 생겨나, 이 a라는 변수를 참조하기 시작했다고 합시다.
해당 메모리에 대해, t1 쓰레드가, a의 값을 1에서 2로 바꿨습니다.
그러면 메인 쓰레드가 이 a의 값을 읽으려 할때, 무슨 값이 찍힐까요?
우리는 최근에 바뀐 2라는 값이 되리라고 예상하겠지만,
실제로 메인 쓰레드의 값은 변하지 않습니다.
같은 변수를 사용하는데 왜 이런 결과가 나올까요?
바로 캐싱 때문입니다.
변수 a가 처음 쓰레드로 인해, CPU의 1코어에 캐싱 되었다면, 메인 쓰레드는 바로 그 1코어의 캐시 메모리를 확인하고 있을테고,
새롭게 나타난 t1 쓰레드가 CPU의 2코어에서 실행된다고 가정하면, t1 쓰레드는 2코어의 캐시 메모리를 사용하기 때문에, 서로간에 차이가 발생하는 것입니다.
이를 가시성 문제라고 하며, 한 쓰레드의 변경이 다른 쓰레드에게 보이지 않는 경우를 말합니다.
실제 하드웨어 적으로 격리된 CPU 공간을 사용하기 때문이죠.
- 위와 같은 상황을 방지하기 위한 키워드가 바로 volatile입니다.
public volatile int counter = 0;
이렇게 사용하며,
해당 변수를 사용하려면 무조건 캐시를 사용하지 말고 메모리에서 가져와 써라! 라는 의미입니다.
안정성을 위하여 병렬 프로그래밍 등에서 최적화를 금지하는 키워드죠.
(C언어에서도 최적화에 대한 반대 키워드로 사용되니 참고하세요.)
- 이상입니다.
'Programming Language' 카테고리의 다른 글
[Java] TLV(Tag-Length-Value) 설명 및 해석 함수 구현 (0) | 2024.10.26 |
---|---|
[Java] JVM Garbage Collector 정리 (2) | 2024.10.13 |
[Java] 자바 Thread Dump 개인정리 (0) | 2024.10.13 |
[Java] JNI 정리 및 개발 방식 정리 (2) | 2024.10.13 |
JVM 메모리 누수 방지를 위한 체크사항 (5) | 2024.09.29 |