멀티쓰레드
여러 작업을 동시에 처리하려고 실행 흐름(Thread)을 여러 개 사용하는 구조
Java 기준으로 프로세스(프로그램) 안에서 여러 개의 작업 흐름이 동시에 실행되는 것
가장 중요한 건
설계
실행 중인 프로그램 자체
VS Code, Chrome, Java 서버
각각 하나의 프로세스
프로세스 내부에서 실제 일을 하는 실행 단위
예를 들어 Spring 서버에서는 HTTP 요청 처리, DB 조회, 파일 복사, 스케줄러, 로그 기록 같은 작업들이 각각 다른 쓰레드에서 돌아감.
한 번에 하나만 처리
작업 A 끝 -> 작업 B 시작 -> 작업 B 끝 -> 작업 C 시작
동시에 여러 작업 수행
쓰레드 1 -> 작업 A
쓰레드 2 -> 작업 B
쓰레드 3 -> 작업 C
CPU 코어 수와 관련 있음
CPU 8코어 쓰레드 100개 가능하지만 실제로는 8개 정도만 진짜 동시에 실행됨 나머지는 CPU가 아주 빠르게 번갈아 실행하는 방식
운영체제가 스케줄링함
컴퓨터 성능 + 메모리 + OS + JVM 설정에 따라 사실상 제한됨
무한정 만들 수 있는 건 아님
쓰레드 하나 생성할 때마다
발생함
CPU가 쓰레드 1 실행 -> 저장 -> 쓰레드 2 실행 -> 저장 -> 쓰레드 3 실행
이렇게 계속 전환하는 것
전환 자체도 비용이 큼
쓰레드가 너무 많으면
실무에서는 보통 Thread Pool 사용 직접 쓰레드 무한 생성 안함.
예)
ExecutorService pool =
Executors.newFixedThreadPool(10);
최대 10개만 동시 실행 나머지는 대기
여러 쓰레드가 동시에 같은 변수 접근
쓰레드1 : count = 5 읽음
쓰레드2 : count = 5 읽음
둘 다 +1 후 저장하면 결과 = 6 돼버림.
원래는 7 이어야 함
synchronized(lock) {
count++;
}
AtomicInteger count =
new AtomicInteger();
count.incrementAndGet();
원자적 연산 보장
내부 동작
1. count 값을 읽음
2. +1 계산
3. 다시 저장
즉 3 단계
Thread1, 2가 거의 동시에 count 읽어서 +1 둘 다 6 저장
-> 이걸 Race Condition 이라고 함
누가 먼저 실행되느냐에 따라 결과가 달라짐
AtomicInteger는 읽기 + 증가 + 저장
전체를 CPU 수준에서 절대 중간에 끼어들 수 없는 하나의 작업처럼 처리함
이걸 원자적(Atomic) 이라고 함.
AtomicInteger 내부가
쉽게 비유하면 count++ 메모장 하나 두고 둘이 동시에 숫자 수정하는 것
AtomicInteger 한 명 수정 끝날 때까지 다른 사람 잠깐 대기 같은 느낌
(실제로는 lock 없이 CAS를 많이 사용)
서로 락 기다리다가 영원히 멈춤
쓰레드 1 : A 락 점유 -> B 기다림
쓰레드 2 : B 락 점유 -> A 기다림
=> 서버 멈춤
서로 락(lock)을 잡은 상태에서 상대 락 기다리다가 영원히 멈추는 현상
항상 A -> B 순서로만 락 획득
규칙 통일 그러면 교착상태 거의 예방 가능
락 범위를 짧게
// 안 좋은 예
synchronized(lock) {
파일복사();
DB조회();
}
// -> IO 작업까지 락 잡고 있음 엄청 위험
tryLock(3, TimeUnit.SECONDS)
3초 안 되면 포기. 무한 대기 방지
가능하면 락 안에서 또 다른 락 획득 안 하기
공유 데이터를 아예 수정 불가능하게
예)
실행 순서에 따라 결과 달라짐
멀티쓰레드의 대표적인 버그
대표 증상
계산 작업
이건 보통 코어 수 정도가 효율적
예) 8코어 -> 8~16 쓰레드
대기 많은 작업
이건 대기 시간이 많아서 쓰레드를 더 많이 둬도 됨
예) 8코어라도 50~200 쓰레드 가능
Spring 서버가 대표적
쓰레드 50개로 파일 복사
CPU는 멀쩡한데 IO가 터짐
실무에선 항상 제한해서 사용
Executors.newFixedThreadPool(10)
또는
ThreadPoolTaskExecutor
IO 작업은 대기 시간이 매우 많음
CPU는 실제 계산보다 기다린느 시간이 훨씬 많음
Files.copy()
이걸 실행하면 내부적으로 디스크 읽기 대기 -> 네트워크 대기 -> OS 버퍼 대기
등이 발생함
이 동안 CPU는 놀고 있을 수도 있음.
쓰레드 하나가 파일 복사 기다리는 동안
로그도 못 씀. DB도 못 감. 다른 요청도 못 받음
원래 독립적으로 처리 가능했던 작업들을 단일 쓰레드에서는 한 실행 흐름 안에서 순차 처리하게 됨
예를 들어 Tomcat
사용자가 동시에 접속하면
사용자 A 요청 -> Thread-1
사용자 B 요청 -> Thread-2
사용자 C 요청 -> Thread-3
이렇게 각각 처리함
로그 기록도 IO
실제로는
파일 열기
쓰기
flush
디스크 반영
디스크 느리면 대기 발생
그래서 Async Logger 사용하기도 함
업무 쓰레드 : 로그 남겨줘 요청만 큐에 넣음
별도 로그 쓰레드 : 실제 파일 기록
log4j2 AsyncAppender 같은 거
하지만 멀티쓰레드도 무조건 좋진 않음
파일 복사 쓰레드를 너무 많이 만들면 디스크 병목, NAS 병목, DB connection 부족 발생
그래서 항상 newFixedThreadPool() 같이 제한을 둠
읽고 쓰는 작업
외부에서 읽기
외부에 쓰기
CPU보다 외부 장치가 훨씬 느림
대략 나노초(ns) 수준
마이크로초 ~ 밀리초(ms) 수준 엄청 느림
Files.readAllBytes()
실제로는
1. 디스크에 요청
2. 디스크가 파일 찾음
3. 읽음
4. OS 버퍼 이동
5. JVM 전달
과정 거침
기다리는 시간 많음
DB 서버 응답 대기
NAS 응답 대기
수 ms ~ 수백 ms.
CPU 입장에선 엄청 긴 시간
그래서 IO Bound 라고 함 CPU 계산보다 기다리는 시간이 더 많은 작업
반대로 CPU Bound 압축, 암호화, 영상 인코딩, AI 연산 이건 CPU 계산이 대부분
네트워크 드라이브 (SMB 공유 폴더 등) 읽는 것도 대표적인 IO 작업
로컬 디스크 IO + 네트워크 통신
로컬 파일 읽기
프로그램 -> OS -> SSD/HDD -> 메모리
거의 컴퓨터 내부에서 끝남
네트워크 드라이브 읽기
프로그램 -> OS -> 네트워크 카드 -> TCP/IP 통신 -> 스위치/공유기 -> NAS 서버 -> NAS 디스크 읽기 -> 다시 네트워크 전송 -> 내 PC 도착
과정 거침
파일 읽기 전에 파일 있는지 열어주고 데이터 보내고 이런 통신이 계속 발생함 (이 자체가 느림)
SSD 내부 속도 : 수백 MB/s ~ GB/s
근데 네트워크는 : 1Gbps = 이론상 약 125MB/s 정도
실제로는 더 느림
상대 서버도 결국 디스크를 읽어야함
큰 파일은 네트워크에서 잘게 쪼개서(packet) 보냄
전송 중 : 재전송, 오류 수정, 순서 맞춤 등 발생
윈도우 공유폴더는 보통 SMB 사용
파일 하나 읽을 때도 내부적으로 인증, 권한 확인, 세션 유지 등 작업 많음
그래서 작은 파일 여러 개가 특히 느림
왜냐하면 파일 열기, 권한 확인, 네트워크 요청이 매번 발생함