📀 운영체제
07 ~ 08. 쓰레드의 이해: Chapter 4. Thread & Concurrency (Part 1 ~ Part 2)
락꿈사
2022. 5. 30. 19:21
4.1 개요
- 스레드
- Light weight process
- CPU 점유의 기본 단위
- 스레드 ID, 프로세스 카운터(PC), 레지스터 집합, 스택으로 구성됨
- 스레드끼리 열린 파일과 같은 운영체제 자원을 공유함
동기
- 클라이언트 - 서버 프로세스의 환경에서
- 웹 서버가 단일 스레드 프로세스로 작동하는 경우
- 한 번에 한 클라이언트만 서비스 할 수 있음
- 클라이언트는 request가 서비스 되기까지 오랜 시간 기다려야 함
- 2가지 해결책
- 요청을 수행할 별도의 프로세스를 생성하는 방법
- 서버에서 서비스 요청이 들어오면 그 요청을 수행할 별도의 프로세스를 생성하는 것
- 프로세스 생성 작업은 많은 시간을 소비하고 많은 자원을 필요로 함
- 비효율적
- 서버에서 서비스 요청이 들어오면 그 요청을 수행할 별도의 프로세스를 생성하는 것
- 프로세스 안에 여러 스레드를 만들어 나가는 방법
- 서버는 클라이언트의 요청을 listen하는 별도의 스레드를 생성
- 요청이 들어오면 요청을 서비스할 새로운 스레드 생성
- 추가적인 요청을 listen하기 위한 작업을 재개
- 효율적
- 요청을 수행할 별도의 프로세스를 생성하는 방법
- 웹 서버가 단일 스레드 프로세스로 작동하는 경우
장점
- 다중 스레드의 장점 4가지
- 응답성responsiveness
- 응용 프로그램의 일부분이 block 되거나, 응용 프로그램이 긴 작업을 수행하더라도 프로그램의 수행이 계속됨
- 사용자에 대한 응답성 증가
- 자원 공유resource sharing
- 프로세스는 공유 메모리와 메시지 전달 기법을 통하여만 자원을 공유할 수 있음
- 스레드는 자동으로 그들이 속한 프로세스의 자원들과 메모리를 공유함
- 경제성economy
- 프로세스 생성을 위해 메모리와 자원을 할당하는 것은 비용이 많이 듬
- 스레드는 자신이 속한 프로세스의 자원을 공유함
- 따라서 스레드를 생성하고 context switch를 하는 것에 경제적임
- 규모 적응성scalability
- 다중 처리기 구조에서 각각의 스레드가 다른 처리기에서 병렬로 수행될 수 있음
- 응답성responsiveness
4.2 다중 코어 프로그래밍
- 다중 코어
- 단일 컴퓨팅 칩에 여러 컴퓨팅 코어를 배치하는 시스템
- 다중 스레드 프로그래밍은 다중 코어를 보다 효율적으로 사용하고 병행성을 향상시키는 기법을 제공함
- 병행 시스템과 병렬 시스템
- 병행 시스템: 모든 작업이 진행되게 하여 둘 이상의 작업을 지원하는 것
- 병렬 시스템: 둘 이상의 작업을 동시에 수행하는 것
- 단일 컴퓨팅 코어가 있는 시스템의 경우
- 처리 코어가 단 한번에 단 하나의 스레드만 실행할 수 있음
- 병행성 o / 병렬성 x
- 여러 코어가 있는 시스템의 경우
- 시스템이 각 코어에 별도의 스레드를 할당할 수 있음
- 일부 스레드가 병렬로 실행될 수 있음
- 병행성 o / 병렬성 o
- 병행 시스템과 병렬 시스템
프로그래밍 도전 과제
- 다중 코어 시스템 프로그래밍을 위해 극복해야 할 5가지 과제
- 테스크 인식identifying tasks
- 응용을 분석하여 독립된 병행 가능 태스크로 나눌 수 있는 영역을 찾는 작업이 필요함
- 균형balance
- 찾아진 부분들이 전체 작업에 균등한 기여도를 가지도록 태스크를 나누어야 함
- 데이터 분리data spliting
- 태스크가 접근하고 조작하는 데이터 또한 개별 코어에서 사용할 수 있도록 나누어야 함
- 데이터 종속성
- 한 태스크가 다른 태스크로부터 오는 데이터에 종속적인 경우 프로그래머가 데이터 종속성을 수용할 수 있도록 태스크의 수행을 잘 동기화 해야 함
- 시험 및 디버깅testing and debugging
- 병행 프로그래밍을 시험하고 디버깅 하는 것은 단일 스레드 응용을 시험하고 디버깅 하는 것보다 훨씬 어려움
- 테스크 인식identifying tasks
암달의 법칙Amdahl's Law
- S: 순차적으로 처리해야만 하는 process의 비율
- N: 코어의 갯수
- 성능 speedup <= 1 / (S + (1-S)/N)
- 예시
- 75%의 병렬 실행 구성 요소와 25%의 순차 실행 구성 요소를 가진 응용이 있다고 가정
- 코어가 2개인 경우: 1.6배의 속도 상향
- 코어가 4개인 경우: 2.28배 속도 향상
- 75%의 병렬 실행 구성 요소와 25%의 순차 실행 구성 요소를 가진 응용이 있다고 가정
- N이 무한대로 가까워지면 속도는 1/S에 수렴함 -> 응용의 순차 실행 부분은 코어를 추가하여 얻을 수 있는 성능 향상에 불균형적인 여향을 미침
병렬 실행의 유형
- 데이터 병렬 실행
- 동일한 데이터의 부분집합을 다수의 계산 코어에 분배한 뒤 각 코어에서 동일한 연산을 실행
- 태스크 병렬 실행
- 데이터가 아니라 태스크(스레드)를 다수의 코어에 분배
4.3 다중 스레드 모델
- 사용자 스레드user thread
- 커널 위에서 지원되며 커널의 지원 없이 관리됨
- 커널 스레드kernel thread
- 운영체제에 의해 직접 지원되고 관리됨
- 현대의 거의 모든 운영체제들은 커널 스레드를 지원함
- 사용자 스레드와 커널 스레드의 3가지 연관 관계
- 다대일 모델
- 일대일 모델
- 다데다 모델
다대일 모델
- 많은 사용자 수준 스레드를 하나의 커널 스레드가 담당
- 스레드 관리는 사용자 공간의 스레드 라이브러리에 의해 행해짐
- 한 스레드가 blocked system call을 할 경우 전체 프로세스가 봉쇄됨
- ex) green thread
일대일 모델
- 각 사용자 스레드를 각각 하나의 커널 스레드가 담당
다대다 모델
- 여러 개의 사용자 스레드를 그보다 작은 수, 혹은 같은 수의 커널 스레드로 멀티 스레드
4.4 스레드 라이브러리
- 스레드 라이브러리
- 프로그래머에게 스레드를 생성하고 관리하기 위한 API를 제공함
- 3가지 종류
- POSIX Pthread
- Windows
- JAVA
- JAVA 스레드 API는 JAVA 프로그램에서 직접 스레드 생성과 관리를 가능하게 함
- 대부분의 JVM 구현은 호스트 운영체제에서 실행되기 때문에 JAVA 스레드 API는 통상 호스트 시스템에서 사용 가능한 스레드 라이브러리를 이용하여 구현됨
- Windows 시스템에서 JAVA 스레드는 Windows API를 사용하여 구현됨
- UNIX, LINUX, macOS 시스템에서 JAVA 스레드는 Pthread를 사용하여 구현됨
Pthreads
- Pthreads: POSIX (IEEE 1003.1c)가 스레드 생성과 동기화를 위해 제정된 표준 API
- 스레드 동작에 관한 명세일 뿐이지 그것 자체를 구현한 것은 아님
- 이러한 명세를 가지고 Linux, macOS를 포함한 많은 시스템에서 Pthreads 명세를 구현함
- EX1) Pthreads를 사용한 다중 스레드 C 프로그램
- 두개의 스레드를 가짐
- main() 함수의 부모 스레드
- runner() 함수에서 합을 계산하는 자식 스레드
- 두개의 스레드를 가짐
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
int sum;
void *runner(void *param);
// 여기서 argv를 받아서
int main(int argc, char const *argv[])
{
pthread_t tid;
pthread_attr_t attr;
pthread_attr_init(&attr);
// 받아온 argv의 첫번째 인자를 runner에 thread로 넘겨줌
// runner 함수가 실행됨
pthread_create(&tid, &attr, runner, argv[1]);
pthread_join(tid, NULL);
printf("sum = %d\n", sum);
}
void *runner(void *param){
int i, upper = atoi(param);
sum = 0;
// 1~n 까지의 합을 구해줌
for (i=1; i<=upper; i++){
sum += i;
}
pthread_exit(0);
}
- EX2) Pthreads를 사용한 다중 스레드 C 프로그램
- 두개의 프로세스를 가짐
- main() 프로세스의 부모 프로세스
- 부모 프로세스의 value에는 아무런 값도 넣지 않았으므로 value = 0이 됨
- pid = fork() 된 자식 프로세스
- 두개의 스레드를 가짐
- 자식 프로세스의 메인 스레드
- 자식 프로세스에서 pthread_create()된 스레드
- 스레드는 자원(전역 변수)을 공유하므로 runner() 함수에서 value = 5를 넣어주는 프로세스를 통해 자식 프로세스들의 value는 5가 됨
- 두개의 스레드를 가짐
- main() 프로세스의 부모 프로세스
- 두개의 프로세스를 가짐
#include <pthread.h>
#include <stdio.h>
#include <wait.h>
#include <unistd.h>
int value = 0;
void * runner(void *param);
int main(int argc, char const *argv[])
{
pid_t pid;
pthread_t tid;
pthread_attr_t attr;
pid = fork();
if (pid == 0){
pthread_attr_init(&attr);
pthread_create(&tid, &attr, runner, NULL);
pthread_join(tid, NULL);
printf("CHILD: value = %d\n", value);
}
else if (pid > 0){
wait(NULL);
printf("PARENT: value = %d\n", value);
}
return 0;
}
void *runner(void *param){
value = 5;
pthread_exit(0);
}
Java 스레드
- 스레드는 JAVA 프로그램 실행의 근본적인 모델이며 Java언어와 API는 스레드의 생성과 관리를 지원하는 풍부한 특성을 제공함
- main() 함수로만 이루어진 단순한 Java 프로그램조차 JVM 내의 하나의 단일 스레드로 수행됨
- Java 스레드는 JVM을 제공하는 어떠한 시스템에서도 사용할 수 있음
- Java 프로그램에서 스레드를 명시적으로 생성하는 2가지 기법
- Thread 클래스에서 파생된 새 클래스를 만들고 run() 메소드를 재정의 하는 방법
- Runnable 인터페이스를 구현하는 클래스를 정의하고 단일 추상 메소드 Public void run()를 정의하는 방법
- Lambda expression으로 새로운 클래스르 선언하지 않고 새로운 메소드를 사용하는 방법
- 1번 방법(Thread 클래스에서 파생된 새 클래스를 만들고 run() 메소드를 재정의) 코드
package com.os_study.ch04;
class MyThread1 extends Thread{
@Override
public void run() {
try{
while(true){
System.out.println("Hello, MyThread!");
Thread.sleep(500);
}
}
catch (InterruptedException ie){
System.out.println("I'm interrupted!");
}
}
}
public class ThreadExample1{
public static void main(String[] args) {
// Thread class 생성
// fork()와 똑같움
// main 메소드에서 실행되고 있던 main thread가 새로운 thread를 생성
MyThread1 myThread1 = new MyThread1();
// start()가 run()을 호출해줌
// 아직 thread 간의 context switch가 일어나지 않음
myThread1.start();
// 따라서 아래 문장이 실행됨
System.out.println("Hello, MyChild");
// context switch가 일어나서 run()이 실행됨.
}
}
- 2번 방법(Runnable 인터페이스를 구현하는 클래스를 정의하고 단일 추상 메소드 Public void run()를 정의) 코드
package com.os_study.ch04;
class MyThread2 implements Runnable{
// Runnable 이라는 인터페이스에는 run() 메소드 하나밖에 없음
public void run() {
try{
while(true){
System.out.println("Hello, Runnable!");
Thread.sleep(500);
}
}
catch (InterruptedException ie){
System.out.println("I'm interrupted!");
}
}
}
public class ThreadExample2{
public static void main(String[] args) {
// MyThread2 인스턴스를 new MyThread2()를 사용하여 생성 후
// Thread 클래스 생성자의 파라미터로 넘겨줌
// main 메소드에서 실행되고 있던 main thread가 새로운 thread를 생성
Thread thread = new Thread(new MyThread2());
// start()가 Thread 클래스의 run()을 호출해 줌 -> MyThread2의 run()을 호출해 줌
// 아직 thread 간의 context switch가 일어나지 않음
thread.start();
// 따라서 아래 문장이 실행됨
System.out.println("Hello, MyRunnable Child!");
// context switch가 일어나서 run()이 실행됨.
}
}
- 3번 방법(Lambda expression으로 새로운 클래스르 선언하지 않고 새로운 메소드를 사용) 코드
package com.os_study.ch04;
public class ThreadExample3 {
public static void main(String[] args) {
Runnable task = () ->{
try {
while (true){
System.out.println("Hello, Lambda Runnable!");
Thread.sleep(500);
}
}
catch (InterruptedException ie){
System.out.println("I'm Interrupted!");
}
};
Thread thread = new Thread(task);
thread.start();
System.out.println("Hello, My Lambda Child!");
}
}
- EX_1 ) join()을 사용하여 자식 스레드가 끝날 때 까지 wait하는 코드
package com.os_study.ch04;
public class ThreadExample4 {
public static void main(String[] args) {
Runnable task = () ->{
for(int i=0; i<5; i++){
System.out.println("Hello, Lambda Runnable!");
}
};
Thread thread = new Thread(task);
// child thread가 수행되기 시작
thread.start();
try {
// wait()과 같은 역할
// child thread가 수행 종료될 때 까지 대기
thread.join();
}
catch (InterruptedException ie){
System.out.println("Parent thread is interrupted!");
}
// child thread가 수행 종료된 후 수행됨
System.out.println("Hello, My Joined Child!");
}
}
- EX_2 ) interrupt()을 사용하여 자식 스레드를 종료시키는 코드
package com.os_study.ch04;
public class ThreadExample5 {
public static void main(String[] args) throws InterruptedException{
Runnable task = () ->{
try{
while(true){
// 0.1.초마다 println 수행
System.out.println("Hello, Lambda Runnable!");
Thread.sleep(100);
}
}
// Interrupted가 걸리면 여기로 빠짐
catch (InterruptedException ie){
System.out.println("I'm Interrupted!");
}
};
Thread thread = new Thread(task);
// child thread가 수행되기 시작
thread.start();
// main thread가 0.5초 기다림
Thread.sleep(500);
// child thread에 interrupt를 걸어줌
thread.interrupt();
// child thread가 수행 종료된 후 수행됨
System.out.println("Hello, My Interrupted Child!");
}
}
4.5 암묵적 스레딩
- 암묵적 스레딩: 스레딩의 생성과 관리 책임을 개발자로부터 컴파일러와 런타임 라이브러리에게 넘겨주는 것
스레드 풀
- 프로세스를 시작할 때 아예 일정한 수의 스레드를 미리 풀로 만들어 둠
- 이 스레드들은 평소에 하는 일 없이 일감을 기다림
- 서버가 요청을 받으면 스레드 풀에 요청을 제출하고 추가 요청 대기를 계속함
- 풀에 사용가능한 스레드가 있으면 깨어나고 요청이 즉시 서비스 됨
- 풀에 사용가능한 스레드가 없으면 사용 가능한 스레드가 생길 때 까지 작업이 대기됨
Fork Join
- fork - join 모델
- fork: 메인 부모 스레드가 하나 이상의 자식 스레드 생성
- join: 자식의 종료를 기다린 후 join 하고 그 시점부터 자식의 결과를 확인하고 결합할 수 있음
- 명시적 스레드 생성의 특징
- 암시적 스레딩에서도 사용될 수 있음
Open MP
- C, C++, FORTRAN으로 작성된 API와 컴파일러 디렉티브의 잡합
- 병렬로 실행될 수 있는 블록을 찾아 병렬 영역Parallel region이라고 부름
- 코드 안에 컴파일러 디렉티브를 삽입함
- 이 디렉티브는 OpenMP 런타임 라이브러리에 해당 영역을 병렬로 실행하라고 지시함
- EX1) OpenMP 사용 코드
#include <omp.h>
#include <stdio.h>
int main(int argc, char const *argv[])
{
// 컴파일러 디렉티브
#pragma omp parallel
{
printf("I am a parallel region!\n");
}
return 0;
}
- EX2) OpenMP를 사용하여 4개의 스레드를 만드는 코드
#include <omp.h>
#include <stdio.h>
int main(int argc, char const *argv[])
{
omp_set_num_threads(4);
#pragma omp parallel
{
printf("OpenMP thread: %d\n", omp_get_thread_num());
}
return 0;
}
- EX3) OpenMP를 사용하여 Parallel한 연산 수행 코드
#include <stdio.h>
#include <omp.h>
#define SIZE 100000000
int a[SIZE], b[SIZE], c[SIZE];
int main(int argc, char const *argv[])
{
int i;
for (i=0; i<SIZE; i++){
a[i] = b[i] = i;
}
#pragma omp parallel for
for (i = 0; i<SIZE; i++){
c[i] = a[i] + b[i];
}
return 0;
}
- EX4) OpenMP를 사용한 일반적인 연산 수행 코드
#include <stdio.h>
#include <omp.h>
#define SIZE 100000000
int a[SIZE], b[SIZE], c[SIZE];
int main(int argc, char const *argv[])
{
int i;
for (i=0; i<SIZE; i++){
a[i] = b[i] = i;
}
for (i = 0; i<SIZE; i++){
c[i] = a[i] + b[i];
}
return 0;
}
- EX3과 EX4의 연산 수행 시간 비교
- Parallel한 연산을 수행한 코드가 real time에서 수행 시간이 더 짧았음
Grand Central Dispatch
- macOS와 iOS 운영체제를 위해 Apple에서 개발한 기술