이 글은 남궁성 자바의 정석 -기초편 강의를 참고하며 작성하였습니다.
멀티 쓰레드란?
멀티 쓰레드는 하나의 프로그램이 여러 개의 스레드를 사용하여 동시에 작업을 수행하는 것을 말합니다.
각각의 스레드는 독립적으로 실행되지만, 하나의 프로세스 내에서 동작합니다.
멀티 스레드를 사용하면, 동시에 여러 개의 작업을 처리할 수 있으므로 CPU 자원을 효율적으로 사용할 수 있습니다.
멀티 쓰레드의 문제
하지만 멀티 스레드를 사용하면, 스레드 간의 동기화 문제, 데드락 등의 문제가 발생할 수 있습니다. 이러한 문제를 해결하기 위해서는 적절한 동기화 기술과 스레드 안전(Thread-safe)한 자료구조를 사용해야 합니다.
코드 실습
코드 실습의 목적
아래의 코드 실습의 목적은 두 가지가 있습니다.
- 멀티 쓰레드를 활용하여, 코드의 효율성을 높이는 연습하기
- 멀티 쓰레드를 활용함로써, 발생하는 동시성 문제 확인해보기
동시성 문제에 관한 내용은 제일 아래에 있습니다.
코드의 목적
코드의 기능은 1 부터 100까지 순서대로 더하고, 출력합니다.
그 후, 실행 시간을 측정합니다.
예상되는 결과 싱글 쓰레드보다 멀티 쓰레드를 사용할 때, 실행 시간이 짧을 것이라고 예상했습니다.
멀티 쓰레드를 활용하지 않는 코드
public class Main {
public static void main(String[] args) {
long startNum = 1;
long endNum = 100;
long sum = 0;
long startTime = System.nanoTime();
for (int i = (int)startNum; i <= endNum; i++) {
sum = sum + i;
}
long endTime = System.nanoTime();
long executionTime = endTime - startTime;
System.out.println(startNum + " 부터 " + endNum + " 까지의 합은 " + sum + "입니다.");
System.out.println("계산을 완료하기 까지 " + executionTime + " ns 의 시간이 걸렸습니다.");
}
}
- 코드 실행 결과
1 부터 100 까지의 합은 5050입니다.
계산을 완료하기 까지 958 ns 의 시간이 걸렸습니다.
멀티 쓰레드를 활용한 코드
쓰레드를 활용하기 위해서는 다음과 같은 객체가 필요하다고 생각했습니다.
- Calculator : 실질적인 연산을 하기위한 객체
- MyThead : Runnalbe 인터페이스를 구현한 쓰레드 객체
Calculator 객체의 코드는 아래와 같습니다.
class Calculator {
private long sum = 0; // 총합을 보기위한 필드
// 부분합을 계산하는 코드
public long calPartialSum(long startNum, long endNum) {
long partialSum = 0;
for (int i = (int)startNum; i <= endNum; i++) {
partialSum = partialSum + i;
}
return partialSum;
}
// 부분합의 결과를 총합에 더해주는 코드
public void addSum(long partialSum) {
sum = sum + partialSum;
}
public long getSum() {
return sum;
}
}
MyThread 객체의 코드는 아래와 같습니다.
class MyThread implements Runnable {
private Calculator calculator;
private long startNum;
private long endNum;
MyThread(Calculator calculator, long startNum, long endNum) {
this.calculator = calculator;
this.startNum = startNum;
this.endNum = endNum;
}
@Override
public void run() {
long partialSum = calculator.calPartialSum(startNum, endNum);
calculator.addSum(partialSum);
}
}
Main 메서드의 코드
public class Main {
public static void main(String[] args) throws InterruptedException {
// 연산을 하기위해 필요한 설정을 초기화 한다.
Calculator calculator = new Calculator();
int threadNum = 4;
long startNum = 1;
long endNum = 100;
long distance = endNum - startNum + 1;
long partialDistance = distance / threadNum;
// 연산에 사용할 다수의 쓰레드를 만들어 준다.
Thread[] threads = new Thread[threadNum];
for (int i = 0; i < threadNum; i++) {
long startNumOfPartial = partialDistance * i + 1;
long endNumOfPartial = partialDistance * i + partialDistance ;
threads[i] = new Thread(new MyThread(calculator, startNumOfPartial , endNumOfPartial));
}
// 다수의 쓰레드를 사용하여 연산을 시작한다.
long startTime = System.nanoTime();
for (int i = 0; i < threadNum; i++) {
threads[i].start();
}
for (int i = 0; i < threadNum; i++) {
threads[i].join();
}
long endTime = System.nanoTime();
long sum = calculator.getSum();
long executionTime = endTime - startTime;
System.out.println(startNum + " 부터 " + endNum + " 까지의 합은 " + sum + "입니다.");
System.out.println("계산을 완료하기 까지 " + executionTime + " ns 의 시간이 걸렸습니다.");
}
}
- 코드 실행 결과
1 부터 100 까지의 합은 5050입니다.
계산을 완료하기 까지 215250 ns 의 시간이 걸렸습니다.
쓰레드와 그 외의 객체를 생성하는 시간은 계산 수행 시간에서 제외하였습니다.
결과 분석
부분 합을 수행했을 때, 소요되는 시간은 아래와 같습니다.
하나의 쓰레드 (main Thread) 로 실행했을 경우 수행 시간 : 958 ns
다수의 쓰레드 (multi Thread) 로 실행했을 경우 수행 시간 : 215250 ns
멀티 쓰레드를 활용했을 때, 1부터 100까지 계산하는 연산 수행 시간이 싱글 쓰레드를 활용했을 때보다 약 200배가 넘도록 배 비효율적 이었습니다.
왜 이런 결과가 나왔나?
아래의 분석은 정확하지 않습니다. 단순히 참고용으로만 봐주시고, 틀린 부분을 알려주시면 감사하겠습니다!!!
제가 예상하는 이유는 아래와 같습니다.
1. 객체에게 메세지를 보내는 오버헤드가 너무 크다.
멀티 쓰레드를 활용할 때, 객체에게 부분합을 하도록 메세지를 보냈습니다.
객체에게 메세지를 보내고, 결과를 반환 받는 과정은 아래의 동작이 필요하다고 생각합니다. (공부 필요)
- 객체의 메모리 주소를 참조해야한다.
- 객체의 메소드 메모리 주소를 다시 참조해야한다.
- 메세지의 반환값을 원하는 영역 메모리에 올려야한다.
위의 메모리 참조는 많은 비용을 소모하는 연산입니다.
2. 현재의 연산은 너무 단순하다.
현재의 코드는 1 부터 100 까지 for 문 을 통해 단순히 더하는 코드입니다.
이러한 연산은 그저 메모리에서 뱅크 하나 읽어와서, 단순히 더하면 되는 코드입니다.
따라서 한번의 메모리 참조로 연산을 진행 할 수 있다고 생각합니다. (공부 필요)
그렇다면 연산의 크기를 증가시킨다면?
객체에게 메세지를 보내는 오버헤드는 중요하지 않을 만큼 연산의 크기가 커지고,
한번의 메모리 참조로는 연산이 불가능해지는 연산을 한다면 어떻게 될까요?
1부터 100까지가 아닌 1부터 1000000000 까지 더하는 연산을 해보겠습니다.
결과는 아래와 같습니다.
- 싱글 쓰레드로 연산했을 때의 결과
1 부터 1000000000 까지의 합은 500000000500000000입니다.
계산을 완료하기 까지 459931167 ns 의 시간이 걸렸습니다.
- 멀티 쓰레드로 연산했을 때의 결과
1 부터 1000000000 까지의 합은 500000000500000000입니다.
계산을 완료하기 까지 121987292 ns 의 시간이 걸렸습니다.
결과는 멀티 쓰레드가 싱글 쓰레드보다 약 4배 효율적입니다.
이는 4개의 쓰레드를 사용했으므로, 직관적으로 그럴 뜻합니다.
그렇다면 쓰레드의 개수를 늘리는 만큼 효율적이게 되는 건가?
싱글 쓰레드와 비교하여, 4개의 쓰레드를 활용할 때, 약 4 배 더 효율적이게 됐습니다.
그렇다면, n 개의 쓰레드를 사용하면 n 배 더 효율적이게 될까요?
결과는 아래와 같습니다.
10개의 쓰레드로 연산할 경우
1 부터 1000000000 까지의 합은 500000000500000000입니다.
계산을 완료하기 까지 92193041 ns 의 시간이 걸렸습니다.
100개의 쓰레드로 연살할 경우 (성능 하락)
1 부터 1000000000 까지의 합은 500000000500000000입니다.
계산을 완료하기 까지 132477375 ns 의 시간이 걸렸습니다.
1000개의 쓰레드로 연산할 경우 (성능 하락 + 결과 이상)
**1** 부터 1000000000 까지의 합은 499831500499500000입니다.
계산을 완료하기 까지 137956958 ns 의 시간이 걸렸습니다.
위의 결과를 봤을 때, 두 가지의 문제가 있는 것 같습니다.
첫 번째 문제점 : N개의 쓰레드를 활용해도, N 배 효율적이지 않다.
n개의 쓰레드를 n배 효율적이지 않습니다.
오히려, 더 성능이 나빠지기도 했습니다.
이 부분에 대해 부분은 따로 공부할 예정입니다.
두 번째 문제점 : 동시성 문제가 발생했다.
1부터 1000000000의 합은 500000000500000000입니다.
하지만 쓰레드 1000개를 사용하여 계산했을 때, 결과는 499831500499500000으로 결과값이 이상합니다.
멀티 쓰레드 프로세스에서는 쓰레드가 다른 쓰레드의 작업에 영향을 미칠 수 있습니다.
이 말은, 각각의 쓰레드가 계산한 결과가, 전체 결과에 반영이 되지 않을 수 있다는 것입니다.
문제가 되는 부분은 아래와 같습니다.
class Calculator {
private long sum = 0;
// 코드 생략
// 문제가 되는 코드
public void addSum(int partialSum) {
sum = sum + partialSum;
}
}
위의 코드에서 sum 이라는 변수에 쓰레드가 계산한 결과 값을 할당하고 있습니다.
그 과정은 아래와 같습니다.
- 변수 sum 을 읽어온다.
- 변수 sum 에 변수partialSum 을 더한다.
- 2 번의 결과를 다시 변수 sum 에 할당한다.
하지만, 멀티 쓰레드 환경에서는 위의 과정에서 문제가 발생합니다.
문제가 발생하는 이유는 아래의 그림과 같습니다.
각각의 쓰레드의 결과는 모두 변수 sum 에 더해줘야 합니다.
하지만, 동시성 문제가 발생했고, 진행중인 작업이 다른 쓰레드에게 간섭받지 않게 하려면 동기화 가 필요합니다.
동기화를 하는 방법
동기화를 하려면 간섭받지 않아야 하는 문장들을 임계 영역 으로 설정 해야합니다.
임계영역은 락(lock)을 얻은 단 하나의 쓰레드 만 출입이 가능합니다.
자바에서는 객체 당 하나의 락을 가지고 있습니다. (가장 중요한 점이라 생각합니다.)
임계 영역을 설정하는 방법
synchronized 키워드를 활용하여 임계영역을 설정 가능합니다.
- 메서드 전체를 임계 영역으로 지정하는 방법
public synchronized void calSum() {
// ...
}
- 특정한 영역을 임계영역으로 지정하는 방법
public void calSum() {
synchronized(this) {
// ...
}
}
임계영역은 갯수도, 영역도 최소화 하는 것이 좋습니다. 왜냐하면, 임계영역은 한번에 하나의 쓰레드만이 들어갈 수 있기 때문입니다. 이는 멀티 쓰레드를 사용하는 목적과 부합되지 않습니다.
따라서 문제가 되는 코드를 수정하면 아래와 같습니다.
class Calculator {
private long sum = 0;
// 코드 생략
// synchronized 키워드를 이용한 동시성 문제 해결
public synchronized void addSum(int partialSum) {
sum = sum + partialSum;
}
}
코드를 수정한 결과, 동시성 문제를 해결할 수 있습니다.
- 1000개의 쓰레드로 연산할 경우 (synchronized 키워드 사용)
1 부터 1000000000 까지의 합은 500000000500000000입니다.
계산을 완료하기 까지 125565292 ns 의 시간이 걸렸습니다.
정리
멀티쓰레드를 사용한다고, 반드시 싱글쓰레드보다 효율적인 것은 아닙니다.
또한 더 많은 쓰레드가, 더 높은 효율성을 보장하지 않습니다.
마지막으로 멀티쓰레드를 사용할 경우 동시성 문제를 생각하여, 코드의 안전성을 보장해야합니다.
공부 해야될 것
- 왜 쓰레드를 많이 사용할수록, 더 효율적이지 않을걸까?
- 객체에 메세지를 보낼때 발생하는 오버헤드는 무엇이고, 왜 생기는 걸까?
'Language > Java' 카테고리의 다른 글
[Java] Garbage Collection (0) | 2023.08.23 |
---|---|
스트림(Stream) 이란? (0) | 2022.11.12 |
문자열을 정수로 바꾸기 (0) | 2022.11.12 |
기반 스트림과 보조 스트림 (1) | 2022.11.10 |