[cs] 스레드와 멀티스레딩
Q1. 카카오톡은 멀티프로세스일까, 단일 프로세스일까?
Mac의 Spotlight에 들어가 Activity Monitor를 검색해 보자. 관련 항목이 여러 개가 뜬다면 멀티 프로세스, 하나만 뜬다면 단일 프로세스이다.
카카오톡은 단일 프로세스인지, 멀티 프로세스인지 헷갈린다. 뭔지는 모르겠지만 ‘키체인’이라는 보조 프로세스를 사용하고 있는 것으로 보인다.
구글의 경우, Helper 프로세스들이 함께 동작하고 있는 것을 볼 수 있다. Google Chrome이라는 주 프로세스, Renderer, GPU, Plugin 까지 여러 개의 프로세스들이 함께 동작하는 확실한 멀티 프로세스 구조를 취하고 있다.
Q2. 그렇다면 왜 이렇게 다른 프로세스 구조를 가지고 있을까?
이는 어플리케이션의 성격과 주요 목적이 다르기 때문이다.
1. 구글 크롬의 멀티 프로세스 구조
- 안정성: 각 탭, 확장 프로그램, 플러그인 등을 독립된 프로세스로 분리함으로써, 하나의 탭에서 오류나 충돌이 발생해도 다른 탭이나 브라우저 전체가 영향을 받지 않게 한다.
- 보안: 각 웹 페이지는 샌드박스 안에서 독립적으로 실행됩니다. 이는 악성 코드나 보안 위협이 한 탭에서만 머물게 하고, 시스템이나 다른 탭에 침투하는 것을 방지한다.
- 성능: 구글 크롬은 각 탭이 별도의 프로세스로 실행됨으로써, CPU의 멀티 코어를 활용할 수 있습니다. 여러 작업을 병렬로 처리하여 더 빠른 응답성과 성능을 제공할 수 있습니다.
- 확장성: 브라우저에서 여러 확장 프로그램과 플러그인이 작동할 수 있기 때문에, 이들을 각각의 프로세스에서 실행하여 충돌을 방지하고 성능을 최적화합니다.
2. 카카오톡의 단일 프로세스에 가까운 구조
- 단순한 UI/UX: 카카오톡은 주로 메신저 서비스로, 메시지 전송, 채팅, 알림 등의 비교적 단순한 작업을 수행한다. 대부분의 기능이 서로 밀접하게 연결되어 있기 때문에, 단일 프로세스로도 충분히 안정적이다.
- 보안 관리: 카카오톡은 별도의 웹 페이지나 플러그인을 처리하는 대신, 자체 보안 시스템을 통해 데이터를 관리한다. 중요한 기능(예: 키체인과의 상호작용)은 별도 프로세스에서 처리된다.
Q3. 카카오톡은 7개의 스레드를 사용한다. 무슨 뜻일까?
스레드는 프로세스 내에서 동작하는 가장 작은 실행 단위이다. 프로세스는 여러 개의 스레드를 가질 수 있다.
프로세스는 코드, 데이터, 스택, 힙을 각각 생성한다.
스레드는 코드, 데이터, 힙을 스레드끼리 서로 공유한다. 그 외의 영역은 각각 생성한다.
카카오톡이 7개의 스레드를 사용한다는 것은, 카카오톡이 여러 작업을 동시에 수행하고 있다는 뜻이다. 예를 들어, 카카오톡은 멀티 스레딩을 통해 병렬적으로 아래의 작업을 수행할 수 있다. (주의!!! 아래의 내용은 가정일 뿐이다. 카카오톡이 여러 스레드로 이런 작업을 수행하지 않을까~ 하는 추측에 적어 본다.)
- UI 업데이트: 사용자의 입력을 받아들여 화면을 업데이트한다. 메시지를 수신 및 발신하면 화면에 뜬다.
- 메시지 전송 및 수신: 서버와 통신하여 새로운 메시지를 주고받는다.
- 알림 처리: 채팅 메시지나 알림 메시지가 도착하면 알림이 뜬다.
- 미디어 처리: 사진이나 동영상 전송, 다운로드 및 미리보기 작업을 수행한다.
- 키체인 접근: 사용자 인증이나 보안 관련 작업을 처리한다.
- 기타 백그라운드 작업: 캐시 관리, 백그라운드 데이터 동기화 등을 수행한다.
- ….?
이를 통해 사진을 다운로드 받으면서도 메시지를 보낼 수 있다. 즉, 한 작업에 다른 작업이 영향을 받지 않고 동시에 진행될 수 있다.
Q4. 멀티 스레딩에는 어떤 장점과 단점이 있을까?
장점:
- 스레드는 프로세스와 달리 독립적인 메모리 공간을 사용하지 않고, 동일한 프로세스 내에서 실행되기 때문에 IPC 같은 자원 소모가 심한 작업이 줄어든다.
- 컨텍스트 스위칭 비용이 적다. 프로세스의 컨텍스트 스위칭은 CPU 캐시를 모두 초기화하고 새로운 프로세스의 정보를 적재해야 하지만, 스레드의 컨텍스트 스위칭은 Register와 Stack만 비우면 된다.
단점:
- 하나의 스레드에서 문제가 생기면 전체에 영향을 준다. 멀티 프로세스는 하나의 프로세스에서 문제가 생겨도 다른 프로세스가 영향받지 않는다.
- 백엔드 서버에서 많은 요청을 동시에 처리할 때, 매번 새로운 스레드를 생성하면 자원 낭비가 심해진다. 이를 방지하기 위해 ExecutorService와 같은 Thread Pool을 사용하여 스레드 수를 미리 지정해두고 스레드를 재사용하면 효율적인 자원 관리를 할 수 있다. 이때, 적절한 풀 크기를 설정하는 것이 중요하다.
ExecutorService executor = Executors.newFixedThreadPool(5);
executor.submit(// 실행할 작업. 예를 들어 1000개의 작업을 5개의 스레드 풀로 나눠서 실행하고 싶을 때);
executor.shutdown();
- 비동기 프로그래밍: 네트워크 I/O나 파일 시스템 접근처럼 블로킹 작업이 많을 때, @Async를 사용하여 요청을 병렬로 처리할 수 있다.
@Service
public class ServiceClass {
@Async
public void AsyncMethod() {
// 병렬로 실행되어야 하는 작업들
}
}
- 동시성 문제: 여러 스레드가 공유 자원에 동시 접근하면 문제가 발생할 수 있다. Synchronized 블록이나, ReentrantLock, Atomic 클래스를 이용해 스레드의 접근을 제어할 수 있지만, 이 또한 지나치게 사용하면 성능 저하가 발생할 수 있기 때문에 사용에 주의를 요한다.
- 이중 syncronized는 자바 내장 락으로, 모니터락을 사용한다. 동시성 문제를 해결할 때 크게 뮤텍스, 세마포어, 모니터가 있는데 이중 모니터 방식을 사용하는 것! 모니터는 쉽게 말하면, 작업을 큐에 넣어 하나씩 순차접근하도록 만든다. 따라서 하나의 스레드만 임계 영역에 접근할 수 있도록 보장한다.
public class SynchronizedExample {
private int counter = 0;
public void increment() {
// synchronized 블록을 사용하여 한 번에 하나의 스레드만 접근가능하도록 함
synchronized (this) {
counter++;
}
}
public int getCounter() {
return counter;
}
public static void main(String[] args) throws InterruptedException {
SynchronizedExample example = new SynchronizedExample();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final counter value: " + example.getCounter());
}
}
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockExample {
private int counter = 0;
// lock을 명시적으로 획득하고 해제, 타임아웃까지 가능하게 함
// 재진입이 가능한 락 (락을 획득한 상태에서 재진입해서 또 락을 획득할 수 있다.)
private final ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
counter++;
} finally {
lock.unlock();
}
}
public int getCounter() {
return counter;
}
public static void main(String[] args) throws InterruptedException {
ReentrantLockExample example = new ReentrantLockExample();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final counter value: " + example.getCounter());
}
}
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicExample {
// lock을 걸지 않고도 안전하게 스레드 간 동기화를 보장하는 방법
// 아무튼 데이터를? 프로세스의 메모리에 올려서 모든 프로세스가 공유하기 만들어, 스레드간 동기화 보장
// 그럼 프로세스의 Data 영역과 Heap 영역 중에 어디에 올라가려나?
private final AtomicInteger counter = new AtomicInteger(0);
public void increment() {
counter.getAndIncrement();
}
public int getCounter() {
return counter.get();
}
public static void main(String[] args) throws InterruptedException {
AtomicExample example = new AtomicExample();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
example.increment();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Final counter value: " + example.getCounter());
}
}
- 데드락(교착상태) 발생: 예를 들어 스레드 1이 자원 A를 점유하고 있는 상태에서 자원 B가 필요한 상황이다. 스레드 2는 자원 B를 점유하고 있으며 자원 A가 필요한 상황이다. 따라서 스레드 1과 2는 자원들을 주지도 받지도 못하고 무한정 서로를 기다리는 교착 상태에 빠지게 된다.
이를 방지하기 위해서 자원 접근 순서를 일관되게 유지하고, 락을 사용할 때 타임아웃을 설정하여 일정 시간 안에 락을 얻지 못하면 다른 작업을 수행하도록 설계할 수 있다.
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class TimeoutLockExample {
private final Lock lock1 = new ReentrantLock();
private final Lock lock2 = new ReentrantLock();
public void process() {
try {
// 두 자원을 항상 lock1 -> lock2 순으로 접근하여 교착 상태 방지
if (lock1.tryLock(1, TimeUnit.SECONDS)) {
try {
if (lock2.tryLock(1, TimeUnit.SECONDS)) {
try {
// Critical section (자원에 접근하는 코드)
System.out.println(
Thread.currentThread().getName() +
": acquired both locks");
} finally {
lock2.unlock();
}
} else {
// lock을 얻지 못한 경우 다른 작업을 수행하게 함
System.out.println(
Thread.currentThread().getName() +
": could not acquire lock2,
performing alternate task");
}
} finally {
lock1.unlock();
}
} else {
// lock을 얻지 못한 경우 다른 작업을 수행하게 함
System.out.println(Thread.currentThread().getName() +
": could not acquire lock1, performing alternate task");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
public static void main(String[] args) {
TimeoutLockExample example = new TimeoutLockExample();
Runnable task = example::process;
Thread t1 = new Thread(task, "Thread-1");
Thread t2 = new Thread(task, "Thread-2");
t1.start();
t2.start();
}
}
- 컨텍스트 스위칭 오버헤드: 스레드에도 컨텍스트 스위칭 오버헤드가 존재한다. 즉 스레드가 많으면 많다고 좋은 게 아니라는 뜻. 적절한 스레드 풀을 유지하는 것이 중요한데, 이때 처리해야 하는 작업이 계산이 많은 작업인지(CPU 바운드), 파일 입출력이 많은 작업인지(I/O 바운드) 살펴보고 적절한 스레드 수를 설정해야 한다. CPU바운드는 CPU 코어 수에 맞춰서, I/O 바운드는 스레드가 기다리는 시간이 길기 때문에 스레드를 더 많이 설정하는 것이 좋다.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class CpuBoundExample {
public static void main(String[] args) {
// 사용 가능한 CPU 코어 수
int cpuCores = Runtime.getRuntime().availableProcessors();
ExecutorService executor = Executors.newFixedThreadPool(cpuCores);
for (int i = 0; i < cpuCores * 2; i++) {
executor.submit(() -> {
long sum = 0;
for (long j = 0; j < 1000000000L; j++) {
sum += j;
}
System.out.println(Thread.currentThread().getName() +
": Calculation complete");
});
}
executor.shutdown();
}
}
사실, db에 락을 걸거나 동시성 문제를 딱히 마주친 적이 없어서, 해당 부분이 참 모호하고 어렵게 느껴졌다. 실제로 서비스에서 이를 구현하고 해결해 볼 방법이 생기면 좋겠다.