본문 바로가기
CS/운영 체제

[백엔드] 스레드

by Hoozy 2023. 3. 30.

스레드 및 동시성

스레드
  • 스레드 ID, 프로그램 카운터(PC), 레지스터 집합, 스택 으로 구성된다.
  • 스레드는 같은 프로세스에 속한 다른 스레드와 코드, 데이터 섹션, 그리고 열린 파일이나 신호와 같은 운영체제 자원들을 공유한다.

다중 스레드

  • 한 프로세스에 2개 이상의 스레드가 생성되는 것.
  • 만약 웹 서버가 클라이어느로부터 웹페이지나 이미지, 소리 등에 대한 요청을 받고, 하나의 웹 서버는 아주 많은 클라이언트들이 병행하게 접근할 수 있다. 이때 만약 단일 스레드라면 자신의 단일 프로세스로 한 번에 하나의 클라이언트만 서비스할 수 있게 되어 클라이언트는 자신의 요구가 서비스 되기까지 매우 긴 시간을 기다려야한다. 이때 프로세스를 더 만드는 것은 매우 많은 시간과 자원을 소비하기 때문에 한 프로세스 내부에 여러 스레드를 만드는 게 효율적이라 다중 스레드가 필요하다.
  • 웹 서버가 다중 스레드화되면 서버는 클라이언트의 요청을 listen하는 별도의 스레드를 생성합니다. 요청이 들어오면 다른 프로세스를 생서하는 것이 아니라, 요청을 서비스할 새로운 스레드를 생성하고 추가적인 요청을 listen하기 위한 작업을 재개한다.
  • 대부분 운영체제 커널도 일반적으로 다중 스레드이다.
  • 장점
    1. 응답성
      • 대조적으로 시간이 오래 걸리는 연산이 별도의 비동기적 스레드에서 실행된다면 응용은 여전히 사용자에게 응답할 수 있다.
    2. 자원 공유
      • 프로세스는 공유 메모리와 메시지 전달 기법을 통하여야만 자원을 공유할 수 있지만 스레드는 자동으로 스레드가 속한 프로세스의 자원들과 메모리를 공유한다.
    3. 경제성
      • 프로세스 생성을 위해 메모리와 자원을 할당하는 것은 비용이 많이든다. 스레드는 자신이 속한 프로세스의 자원들을 공유하기 때문에, 스레드를 생성하고 문맥 교환하는 것이 더욱 경제적이다.
    4. 규모 적응성
      • 다중 처리기 구조에서는 각각의 스레드가 다른 처리기에서 병렬로 수행될 수 있다.
다중 코어 프로그래밍
  • 요즘 CPU는 단일 컴퓨터 칩에 여러 컴퓨팅 코어를 배치하는 것이다.
  • 각 코어에 운영체제에 별도의 CPU로 보인다. 이러한 시스템을 다중 코어라고 하며 다중 코어 프로그래밍은 이러한 여러 컴퓨팅 코어를 보다 효율적으로 사용하고 병행성을 향상하는 기법을 제공한다.
  • 여러 코어가 있는 시스템에서 병행성은 시스템이 각 코어에 별도의 스레드를 할당할 수 있기 때문에 일부 스레드가 병렬로 실행될 수 있음을 의미한다.
  • 동시성(concurrency)과 병렬성(parallelism)의 차이점
    • 동시성 : 모든 작업이 진행되게 하여 둘 이상의 작업을 지원한다.
    • 병렬성 : 둘 이상의 작업을 동시에 수행할 수 있다.
    • 따라서 병렬성 없이 동시성을 가질 수 있다.
다중 스레드 모델
  • 스레드를 위한 자원은 사용자 스레드를 위해서는 사용자 수준에서 또는 커널 스레드를 위해서는 커널 수준에서 제공된다.
  • 사용자 스레드는 커널 위에서 지원되며 커널의 지원 없이 관리된다. 반면에 커널 스레드는 운영체제에 의해 직접 지원되고 관리됩니다.
  • 현재 거의 모든 운영체제들은 커널 스레드를 지원한다.

모델 종류

  1. 다대일 모델 (Many To One Model)
    • 많은 사용자 수준 스레드를 하나의 커널 스레드로 사상한다.
    • 스레드 관리는 사용자의 공간의 스레드 라이브러리에 의해 행해져서 효율적이라 할 수 있다.
    • 하지만, 한 스레드가 봉쇄형 시스템 콜을 할 경우, 전체 프로세스가 봉쇄된다.
    • 또한, 한 번에 하나의 스레드만이 커널에 접근할 수 있기 때문에, 다중 스레드가 다중 코어 시스템에서 병렬로 실행될 수 없다.
    • 다중 처리 코어의 이점을 살릴 수 없기 때문에 현재는 거의 사용하지 않는다.
  2. 일대일 모델 (One To One Model)
    • 각 사용자 스레드를 각각 하나의 커널 스레드로 사상한다.
    • 하나의 스레드가 봉쇄적 시스템 콜을 호출하더라도 다른 스레드가 실행될 수 있기 때문에 다대일 모델볻 더 많은 병렬성을 제공한다.
    • 또한 이 모델은 다중 처리기에서 다중 스레드가 병렬로 수행되는 것을 허용한다.
    • 이 모델의 단점은 사용자 스레드를 만들려면 해당 커널 스레드를 만들어야하며, 많은 수의 커널 스레드가 시스템 성능에 부담을 줄 수 있다.
    • 현재 리눅스가 이 모델을 구현한다.
  3. 다대다 모델 (Many To Many Model)
    • 여러 개의 사용자 수준 스레드를 그보다 작은 수 혹은 같은 수의 커널 스레드로 멀티플렉스한다.
    • 커널 스레드의 수는 응용프로그램이나 특정 기계에 따라 결정된다.
      • 개발자가 원하는 만큼의 사용자 수준 스레드를 생성하도록 허용하지만, 커널은 한 번에 하나의 커널 스레드만 스케쥴 할 수 있기 때문에 진정한 병렬 실행을 획득할 수 없다.
    • 일대일 모델은 더 많은 병행 실행을 제공하지만, 개발자가 한 응용 내에 너무 많은 스레드를 생성하지 않도록 주의해야 한다.
    • 다대다 모델은 이러한 두 가지 단점들을 어느정도 해결했고, 개발자가 필요한 만큼 많은 사용자 수준 스레드를 생성할 수 있다.
    • 그리고 상응하는 커널 스레드가 다중 처리기에서 병렬로 수행될 수 있습니다. 또한 스레드가 봉쇄형 시스템 콜을 발생시켰을 때 커널이 다른 스레드의 수행을 스케줄 할 수 있다.
    • 다대다 모델의 변형은 여전히 많은 사용자 스레드를 적거나 같은 수의 커널 스레드로 멀티플렉스 시키지만 또한 한 사용자 스레드가 하나의 커널 스레드에만 연관되는 것을 허용한다.
    • 이 변형을 때로 두 수준 모델(two-level-model)이라고도 부른다.
    • 위에 3가지 모델 중 가장 융통성이 있지만, 실제로는 구현하기가 어렵다.
    • 현재 시스템에서 처리 코어 수가 증가함에 따라 커널 스레드 수를 제한하는 것의 중요성이 줄어들어서 결과적으로 대부분의 운영체제는 이제 일대일 모델을 사용한다.
다중 스레드 구현 시 고려해야할 점

fork() 와 exec() 차이

  • fork()와 exec() 모두 한 프로세스가 다른 프로세스를 실행시키기 위해 사용된다.
  • fork() 시스템 호출은 새로운 프로세스를 위한 메모리를 할당하고, fork()를 호출한 프로세스를 새로운 공간으로 전부 복사하게 되고, 원래 프로세스는 원래 프로세스대로 작업을 실행하고 fork()를 이용해서 생성된 프로세스도 그 나름대로 fork() 시스템 콜이 수행된 라인의 다음 라인부터 실행이 된다. -> 새로 생성된 프로세스는 원래의 프로세스와 똑같은 코드를 가지고 있다.
    • fork()의 결과는 프로세스가 하나 더 생기는 것이다. -> 프로세스 id인 PID 가 완전히 다른 또 하나의 프로세스가 생기는 것
  • exec()는 fork()처럼 새로운 프로세스를 위한 메모리를 할당하지 않고, exec()를 호출한 프로세스가 아닌 exec()에 의해 호출된 프로세스만 메모리에 남게된다. 반면 exec() 실행의 결과로 생성되는 새로운 프로세스는 없고, exec() 호출한 프로세스의 PID가 그대로 새로운 프로세스에 적용이 되며, exec()를 호출한 프로세스는 새로운 프로세스에 의해 덮여지게 된다.
    • exec 에는 4가지가 있는데 간단하게 구분해보겠습니다. -> execl, execlp, execv, execvp
    • I 계열(execl, execlp)과 v 계열(execv, execvp)로 비교
      • I 계열 : 인자를 열거하는 방식이 나열형
      • v 계열 : 인자를 열거하는 방식이 배열형
    • P 계열(execlp, execvp)과 아닌 계열(execl, execv)로 비교
      • P 계열 : 경로를 지정해주면, 현재/절대 경로를 기준으로 찾게 됩니다. -> 경로로 실행파일을 결정
      • 아닌 계열(path) : path에 잡혀있으면 실행됩니다. -> 실행파일의 이름만 지정

1. Fork() 및 Exec() 시스템 콜

  • 만일 한 프로그램의 스레드가 fork()를 호출하면 새로운 프로세스는 모든 스레드를 복제해야 할 지 아니면 한 개의 스레드만 가지는 프로세스여야 할 지에 대한 의문을 가질 수 있다. 전자는 모든 스레드를 복사하는 것, 후자는 fork()를 호출한 스레드만 복제하는 것이다.
  • exec() 시스템 콜은 어떤 스레드가 exec() 시스템 콜을 부르면 exec()의 매개변수로 지정된 프로그램이 모든 스레드를 포함한 전체 프로세스를 대체시킵니다.
  • 두 버전의 fork() 중 어느 쪽을 선택할 것인지는 응용프로그램에 달려있다.
  • fork()를 부르자마자 다시 exec()를 부른다면 모든 스레드를 다 복제해서 만들어주는 fork()는 불필요하다. 왜냐하면 exec()에서 지정한 프로그램이 모든 것을 다시 대체할 것이기 때문에 이 경우에는 fork() 시스템 콜을 호출한 스레드만 복사해주는 것이 적절하다. 그러나 새 프로세스가 fork() 후 exec()를 하지 않는다면 새 프로세스는 모든 스레드들을 복제해야 한다.

2. 신호 처리

  • 신호는 유닉스에서 프로세스에 어떤 이벤트가 일어났음을 알려주기 위해 사용되고, 신호는 알려줄 이벤트의 근원지나 이유에 따라 동기식 또는 비동기식으로 전달될 수 있다.
    • 동기식 또는 비동기식 상관 없이 모든 신호는 아래 형태와 같이 전달된다.
    1. 신호는 특정 이벤트가 일어나야 생성된다.
    2. 생성된 신호가 프로세스에 전달된다.
    3. 신호가 전달되면 반드시 처리되어야 한다.
  • 동기식 신호
    • 불법적인 메모리 접근, 0으로 나누기 등 실행 중인 프로그램이 이러한 행동을 하면 신호가 발생합니다.
    • 신호를 발생시킨 연산을 수행한 동일한 프로세스에 전달됩니다 -> 동기식이라고 간주하는 이유
  • 비동기식 신호
    • 신호가 실행 중인 프로세스 외부로부터 발생하면 그 프로세스는 신호를 비동기식으로 전달받는다. 이러한 신호의 예에는 특수한 키를 눌러서 프로세스를 강제 종료시키거나 타이머가 만료되는 경우가 포함되고 비동기식 신호는 통상 다른 프로세스에 전달된다.
  • 모든 신호는 둘 중 하나의 처리기에 의해 처리된다.
    1. 디폴트 신호 처리기
    2. 사용자 정의 신호 처리기
    • 모든 신호마다 커널이 실행시키는 디폴트 신호 처리기가 있다. 이 디폴트 처리기는 신호를 처리하기 위해 호출되는 사용자 정의 처리기에 의해 대체될 수 있다. 신호는 다른 방식으로 처리될 수 있다. 일부 신호는 무시될 수 있지만 다른 신호(불법 메모리 액세스 등)는 프로그램을 종료하여 처리된다.
  • 다중 스레드 프로그램에서 신호 처리는 매우 복잡하므로 어느 스레드에 신호를 전달해야 하는지가 중요하다. 일반적으로는 어디에 전달해야 하는지 선택 방법이 있다.
    1. 신호가 적용될 스레드에게 전달한다.
    2. 모든 스레드에 전달한다.
    3. 몇몇 스레드에만 선택적으로 전달한다.
    4. 특정 스레드가 모든 신호를 전달 받도록 지정한다.
  • 신호를 전달하는 방법은 신호의 유형에 따라 다르다. 예를 들어 동기식 신호는 그 신호를 야기한 스레드에 전달되어야 하고 다른 스레드에 전달되면 안된다.
  • 그러나 비동기식 신호의 경우는 명확하지 않다. -> 특수한 키를 쳐서 그 프로세스를 강제 종료하는 신호와 같은 어떤 비동기식 신호는 그 프로세스 내 모든 스레드에 전달되어야 한다.
  • 신호를 전달하는데 사용되는 표준 유닉스 함수는
int kill(pid_t pid, int signal)
  • 위와 같습니다. 이 함수는 특정 신호가 전달될 프로세스(pid)를 지정한다. 대부분의 다중 스레드 유닉스는 스레드에 받아들일 신호와 봉쇄할 신호를 지정할 수 있는 선택권을 줍니다. 따라서 어떤 경우에는 비동기식 신호를 봉쇄하지 않고 있는 스레드들에게만 신호를 전달해야 할 수 있다. 하지만 신호는 오직 한 번만 처리되어야 하기 때문에 그 신호를 봉쇄하지 않고 있는 첫 번째 스레드에만 신호가 전달됩니다. POSIX Pthreads는 tid로 지정된 스레드에만 전달이 되도록 허용하는 다음과 같은 함수를 제공한다.
int pthread_kill(pthread_t tid, int signal)
  • windows는 신호를 명시적으로 지원하지는 않지만 비동기식 프로시저 호출(APC) 이라는 것을 사용해서 이를 대리 실행할 수 있다. APC는 사용자 스레드들이 특정 이벤트의 발생을 전달 받았을 때 호출될 함수를 지정할 수 있게 한다. 이름이 의미하는 바와 같이 APC는 유닉스의 비동기식 신호와 유사하다. 그러나 유닉스에서는 다중 스레드 환경에서 신호를 어떻게 처리해야 할 지를 고민해야 하지만 APC는 프로세스에 전달되는 것이 아니라 특정 스레드에게 전달되기 때문에 좀 더 간단하다.

3. 스레드 취소

  • 스레드가 끝나기 전에 그것을 강제 종료시키는 작업을 뜻한다.
  • 예를 들어 여러 스레드가 데이터베이스를 병렬로 검색하고 있다가 그 중 한 스레드가 결과를 찾았다면 나머지 스레드는 취소되어도 된다. 또 다른 경우는 웹 브라우저에서 사용자가 웹 페이지를 더는 적재하지 않기 위해 스톱 버튼을 클릭할 수도 있다. 종종 웹 페이지는 여러 스레드들을 사용하여 적재됩니다.(각 이미지는 별도의 스레드에 의해 적재된다.) 사용자가 스톱 버튼을 누르면, 웹 페이지를 가져오던 모든 스레드가 취소된다.
  • 이처럼 취소되어야 할 스레드를 목적 스레드라고 한다. 목적 스레드의 취소는 다음과 같은 두 가지 방식으로 발생할 수 있다.
    1. 비동기식 취소 : 한 스레드가 즉시 목적 스레드를 강제 종료시킨다.
    2. 지연 취소 : 목적 스레드가 주기적으로 자신이 강제 종료되어야 할지를 점검한다. 이 경우 목적 스레드가 순서에 맞게 강제 종료될 수 있는 기회가 만들어진다.
  • 스레드 취소를 어렵게 만드는 것은 취소 스레드들에 할당된 자원 문제이다. 또한 스레드가 다른 스레드와 공유하는 자료구조를 갱신하는 도중에 취소 요청이 와도 문제가 된다. 후자는 비동기식 취소에 경우 더 심각해진다. 종종 운영 체제는 취소된 스레드로부터 시스템 자원을 회수할 수도 있지만, 모든 시스템 자원을 회수하지 못하는 경우도 있다. 따라서 비동기식으로 스레드를 취소하면 필요한 자원을 다 사용가능한 상태로 만들지 못할 수도 있다.
  • 이와 반대로 지연 취소의 경우 한 스레드가 목적 스레드를 취소해야 한다고 표시하지만 실제 취소는 목적 스레드가 취소 여부를 결정하기 위한 플래그를 검사한 이후에 일어난다. 스레드는 자신이 취소되어도 안전하다고 판단되는 시점에서 취소 여부를 검사할 수 있다.
  • Pthreads 에서는 pthread_cancel() 함수를 사용하여 스레드를 취소할 수 있다. 목적 스레드의 식별자가 이 함수의 매개변수로 전달된다. 아래 코드는 스레드를 생성하고 이어서 취소하는 예시이다.
pthread_t pid; int pthread create(pid, 0, worker, NULL);

int pthread cancel(pid);

int pthread join(pid, NULL);
  • pthread_ cancel()을 호출하면 대상 스레드를 취소하라는 요청만 표시됩니다. 그러나 실제 취소는 요청을 처리하기 위해 대상 스레드가 설정되는 방식에 달려 있다. 대상 스레드가 최종적으로 취소되면 취소 스레드의 pthread join() 호출이 반환됩니다. Phthread는 3가지 취소 모드를 지원한다.
  • Pthreads 는 스레드가 취소를 활성 또는 비활성 할 수 있어서 취소가 비활성화 되어 있으면 스레드를 취소할 수 없다.
  • 기본 취소 유형은 취소이다. 그러나 스레드가 취소 점에 도달한 경우에만 취소가 발생한다. POSIX 및 표준 C 라이브러리에서 대부분의 블로킹 시스템 콜은 취소 점으로 정의되며 리눅스 시스템에서 MAN PTHREADS 명령을 호출할 때 나열된다. 예를 들어, read() 시스템 콜은 read()에서 입력을 기다리는 동안 봉쇄된 스레드의 취소를 허용하는 취소 점이다.

4. 스레드-로컬 저장 장치

  • 한 프로세스에 속한 스레드들은 그 프로세스의 데이터를 모두 공유한다. 이와 같은 데이터 공유는 다중 스레드 프로그래밍의 장점 중 하나이다. 그러나 상황에 따라서는 각 스레드가 자기만 액세스 할 수 있는 데이터를 가져야 할 필요도 있다. 이런 데이터를 스레드-로컹 저장장치(TLS)라고 한다.
  • 예를 들어, 트랜잭션 처리 시스템에서 각 트랜잭션을 독립된 스레드가 처리해 주고, 각 트랜잭션은 고유한 트랜잭션 식별자가 주어진다고 가정해보자, 이때 스레드마다 고유한 식별자를 연관시키기 위해서는 스레드 로컬 저장소가 있어야 한다.
  • TLS를 지역 변수와 혼동하기 쉽다. 그러나 지역 변수가 하나의 함수가 호출되는 동안에만 보이지만 TLS는 전체 함수 호출에 걸쳐 보인다. 또한 개발자가 스레드 생성 과정에 대해 제어할 수 없는 경우(스레드 풀과 같은 암묵적 기법을 사용하는 경우) 다른 방법이 필요하다.
  • 어떤 면에서 TLS는 정적 데이터와 유사하다. 차이점은 TLS 데이터는 스레드마다 고유하다는 것이다. -> TLS는 보통 static을 선언된다.
  • 대부분의 스레드 라이브러리 및 컴파일러는 TLS를 지원한다. 예를 들어, Javasms ThreadLocal<T> 객체에 대한 set(), get() 메소드와 함께 ThreadLocal<T> 클래스를 제공한다.

5. 스케줄러 액티베이션

  • 다중 스레드 프로그램과 관련하여 마지막으로 고려할 문제를 스레드 라이브러리와 커널의 통신 문제입니다. 이 통신은 위에서 논의한 다대다 및 두 수준 모델에서 반드시 해결해야 할 문제이다. 이러한 통신의 조정은 응용프로그램이 최고의 성능을 보이도록 보장하기 위하여 커널 스레드의 수를 동적으로 조절하는 것을 가능하게 한다.
  • 다대다 또는 두 수준 모델을 구현하는 많은 시스템 사용자와 커널 스레드 사이에 중간 자료구조를 두고, 이 자료구조는 통산 경량 프로세스 또는 LWP(LightWeight-Process) 라고 불린다.
  • 사용자 스레드 라이브러리에 LWP 방식은 응용이 사용자 스레드를 수행하기 위하여 스케줄 할 가상 처리기처럼 보인다. 각 LWP는 하나의 커널 스레드에 부속되어 있으며 물리 처리기에서 스케줄하는 대상은 바로 이 커널 스레드이다. 입출력이 완료되기를 기다리는 동안 같이 커널 스레드가 봉쇄되면 LWP도 같이 봉쇄된다. 이 연관을 따라 LWP에 부속된 사용자 수준 스레드도 역시 봉쇄된다.
  • 응용은 효율적으로 실행되기 위하여 임의의 개수의 LWP를 필요로 할 수도 있다. 하나의 처리기 상에서 실행되는 CPU 중심 응용을 고려해 보자. 이 시나리오에서 한 순간에 오직 하나의 스레드만이 실행될 수 있다. 따라서 하나의 LWP이면 충분하다. 그러나 입출력 중심 응용은 여러 개의 LWP를 필요로 할 수도 있다. 통상 동시에 발생하는 봉쇄형 시스템 콜마다 하나의 LWP가 필요하다. 예를 들어 서로 다른 5개의 파일 읽기 요청이 발생했다고 가정하자, 모든 LWP가 입출력 완료를 기다리면서 커널 안에서 대기할 수 있기 때문에 5개의 LWP가 필요하다. 만일 프로세스가 4개의 LWP만을 가지고 있다면 다섯 번째 요청은 하나의 LWP라도 커널에서 복귀할 때까지 기다려야 한다.
  • 사용자 스레드 라이브러리와 커널 스레드 간의 통신 방법의 하나는 스케줄러 액티베이션이라고 알려진 방법이다. 이것은 다음과 같이 동작한다. 커널은 응용에 가상 처리기(LWP)의 집합을 제공하고 응용은 사용자 스레드를 가용한 가상 처리기로 스케줄 합니다. 게다가 커널은 응용에게 특정 이벤트에 대해 알려줘야 한다. 이 프로시저를 upcall이라고 하고, upcall은 스레드 라이브러리의 upcall 처리기에 의해 처리되고, upcall 처리기는 가상 처리기 상에서 실행되어야 한다.
  • upcall을 일으키는 한 이벤트는 응용 스레드가 봉쇄하려고 할 때 발생합니다. 이 시나리오에서 커널은 스레드가 봉쇄하려고 한다는 사실과 그 스레드의 식별자를 알려 주는 upcall 을 합니다. 그런 후에 커널은 새로운 가상 처리기를 응용에 할당합니다. 응용은 이 새로운 가상 처리기상에서 upcall 처리기를 수행하고 이 upcall 처리기는 봉쇄 스레드의 상태를 저장하고 이 스레드가 실행 중이던 가상 처리기를 반환합니다. 그리고 upcall 처리기는 새로운 가상 처리기에서 실행 가능한 다른 스레드를 스케줄 합니다. 봉쇄 스레드가 기다리던 이벤트가 발생하면 커널은 이전에 봉쇄되었던 스레드가 이제 실행할 수 있다는 사실을 알려주는 또 다른 upcall을 스레드 라이브러리에 수행합니다. 이 이벤트를 처리하는 upcall 처리기도 가상 처리기가 필요하고 커널은 새로운 가상 처리기를 할당하거나 사용자 스레드 하나를 선점하여 그 처리기에서 이 upcall 처리기를 실행합니다. 봉쇄가 풀린 스레드를 실행 가능 상태로 표시한 후에 응용은 강요한 가상 처리기상에서 다른 실행 가능한 스레드를 실행합니다.
요약
  • 스레드는 CPU 사용의 기본 단위를 나타내며 동일한 프로세스에 속하는 스레드는 코드 및 데이터를 포함하여 많은 프로세스 자원을 공유한다.
  • 다중 스레드 응용프로그램에는 응답성, 자원 공유, 경제성, 확장성 이라는 4가지 주요 이점이 있다.
  • 여러 스레드가 진행 중인 경우 동시성이 존재하는 반면에, 여러 스레드가 동시에 진행 중인 경우 병렬성이 존재한다. 단일 CPU가 있는 시스템에서는 오로지 동시성만 가능하고, 병렬성은 여러 CPU를 제공하는 다중 코어 시스템이 필요하다.
  • 다중 스레드 응용프로그램을 설계하는 데 몇가지 도전과제가 있다. 작업 분할 및 균형 조정, 서로 다른 스레드 간에 데이터 분할 및 데이터 종속성 식별이 포함된다. 마지막으로, 다중 스레드 프로그래믄 테스트 및 디버깅에 특히 어려움이 있다.
  • 사용자 응용 프로그램은 사용자 수준 스레드를 생성하며, 이 스레드는 궁극적으로 CPU에서 실행되도록 커널 스레드에 매핑되어야 한다. 다대일 모델은 많은 사용자 수준 스레드를 하나의 커널 스레드에 매핑한다. 다른 접근법으로는 일대일 및 다대다 모델이 있고, 현재는 일대일 모델이 대부분에 쓰인다.
  • 스레드는 비동기 또는 지연 취소를 사용하여 종료될 수 있다. 비동기 취소는 스레드가 업데이트를 수행하는 중이라도 스레드를 즉시 중지한다. 지연 취소는 스레드에 종료해야 한다고 통지하지만 스레드는 질서 정연하게 종료된다. 대부분의 경우 비동기 종료보다 지연 취소가 선호된다.
자바의 스레드
  • 이제 자바에서의 스레드를 알아보겠습니다.
  • 자바의 쓰레드는 동시성으로 동작한다. 동시성은 논리적으로 여러 작업을 동시에 처리하는 다중 쓰레드 동작이고, 여러 쓰레드가 물리적으로 동시에 실행되지 않는다.
  • 실제 동작은 여러 쓰레드의 수행시간을 쪼개어 작동한다.

자바 JVM의 Main Thread

  • 자바는 JVM을 통해 멀티 스레드를 구성하여 동시성있게 동작하여 동시에 여러 작업을 수행할 수 있다. 하지만 동시에 수행되는 보장되어 있지 않다.
  • JVM은 maiin() 메소드를 찾아 Main Thread를 동작시키고, 기본적으로 메인 스레드는 다른 스레드와 차별화 되어 있는게 아니라 처음에 수행되는 쓰레드이다. 그러므로 메인 스레드의 종료와 다른 스레드의 종료는 관련 없이 별개로 동작한다. 메인 스레드가 종료되어도 다른 모든 스레드가 종료되어야 JVM의 동작이 멈춘다.

Thread Class

  • 자바에서 스레드는 java.lang.Thread 클래스를 통해 사용한다. Thread 클래스의 속성인 Runnable targer 구현 객체의 run() 메소드의 구현부에서 해당 스레드의 실제 동작을 정의하며 start() 메소드를 통해 스레드를 스케줄러를 통해 동작시킬 수 있다.

Thread Class 의 주요 메소드

Thread 생성자

  • Thread 객체를 생성할 때 Runnable 객체, 쓰레드 명, 쓰레드 그룹 등을 받아 객체를 생성한다.
  • private void init(...) 메소드를 통해 스레드를 생성한다.
private Runnable target;    

public Thread() {  
init(null, null, "Thread-" + nextThreadNum(), 0);  
}

//runnable 구현 객체를 매개로 받은 후 init() 메서드로 쓰레드 생성  
public Thread(Runnable target) {  
init(null, target, "Thread-" + nextThreadNum(), 0);  
}

//쓰레드의 이름을 부여. 부여하지 않으면 0번 부터 차례로 인덱싱  
public Thread(String name) {  
init(null, null, name, 0);  
}

start()

  • start() 메소드를 수행하면 native 메소드인 start0() 를 통해 스레드를 스케줄러에 포함시킬 수 있다.
  • 스케줄러에 포함된 쓰레드는 쓰레드 큐 내에서 runnable로 대기하다가 수행 차례가 되면 run() 메소드를 호출하여 동작을 수행한다.
  • 스레드는 단 한 번만 start() 메소드가 호출될 수 있으며 Terminate(제거) 되었거나 waiting 인 상태여도 start() 메소드를 호출할 수 있다.
public synchronized void start() {  
// 해당 쓰레드는 시작되지 않은 상태여야 한다. 한번 수행된 쓰레드는 다시 수행할 수 없음.  
if (threadStatus != 0)  
throw new IllegalThreadStateException();

group.add(this);

    // started 플래그를 통해 예외를 받는다.
boolean started = false;
try {
    start0();
    started = true;
} finally {
    try {
        if (!started) {
            group.threadStartFailed(this);
        }
    } catch (Throwable ignore) {
        /* do nothing. If start0 threw a Throwable then
          it will be passed up the call stack */
    }
}

}

private native void start0();

run()

  • start() 메소드를 실행하면 스케줄러 내에서 실제 동작하는 부분이다. Thread 클래스는 Runnable 인터페이스의 run() 메소드를 구현했다.
  • target.run() 메소드를 수행하기 때문에 생성자를 통해 target 을 지정하거나 Thread 클래스를 상속해 run() 메소드를 재정의해야한다.
  • 스레드의 start() 메소드를 호출하지 않고, run() 메소드를 직접 호출하면 스레드는 스케줄러는 포함되지 않고 일반 메소드를 호출한 것처럼 동작합니다. 이 때문에 run() 메소드를 수행하는 스레드는 호출한 스레드가 됩니다.
private Runnable target;

@Override  
public void run() {  
if (target != null) {  
target.run();  
}  
}

sleep()

  • sleep() 메소드를 통해 스레드를 waiting pool 로 보내 대기시킬 수 있다.
  • static 메소드이므로 호출한 스레드가 대기 상태에 들어간다.
  • ms 단위의 시간을 매개변수로 받아 해당 시간만큼 스레드를 대기시킨다.
  • 인터럽트가 발생하면 다시 Runnable 상태가 되며 InterruptedException을 발생시킨다.
public static native void sleep(long millis) throws InterruptedException;

기타 메소드

  • public static native Thread currentThread();
    • static 메소드로 호출한 스레드의 객체를 반환
  • String getName(), Void setName()
    • 스레드 명에 대한 getter, setter
  • public final native boolean isAlive();
    • 스레드가 현재 수행되고 있는지를 boolean으로 반환

스레드의 구현

  • 자바에서는 Runnable 인터페이스를 구현한 객체의 run() 메소드를 호출하여 스레드를 동작시킬 수 있다. Thread 클래스도 Runnable 을 구현 했기 때문에 Thread 클래스를 상속 받아 run() 메소드를 재정의 하는 것으로도 스레드를 구현할 수 있다.

Runnable 인터페이스

  • Interface Runnable 을 구현하는 클래스를 생성
  • void run() 메소드를 오버라이딩하여 스레드의 동작 정의
  • 스레드 객체를 만들어서 Runnable 을 구현한 객체를 생성자의 매개변수로 넘김
  • 해당 스레드 객체의 start() 메소드로 스레드 수행
public class myRunnable implements Runnable{  
@Override  
public void run(){  
//... do something...  
}  
}

public static main(String\[\] args){  
myRunnable mr = new myRunnable();  
Thread myThread = new Thread(mr);  
myThread.start();  
}

Thread 클래스 상속

  • Thread 클래스를 상속한 클래스 구현
  • void run() 메소드를 오버라이딩
  • 구현 객체를 생성하고 객체의 start() 메소드로 run() 메소드 수행 시작
public class myThread extends Thread{  
@Override  
public void run(){  
//... do something...  
}  
}

public static main(String\[\] args){  
Thread myThread = new myThread();  
// upcasting  
myThread.start();  
}
  • Thread 클래스를 상속하면 구현이 더 간단하지만 다른 클래스를 상속할 수 없기 때문에 상황에 맞게 스레드를 구현한다.

참고 자료

https://dkswnkk.tistory.com/401?category=513905
https://jwprogramming.tistory.com/55
https://velog.io/@tomato2532/JAVA-Thread-1-%EC%9E%90%EB%B0%94%EC%9D%98-%EC%93%B0%EB%A0%88%EB%93%9C

'CS > 운영 체제' 카테고리의 다른 글

[백엔드] 리눅스 터미널 명령어  (0) 2023.03.31
[백엔드] JVM 메모리 관리  (0) 2023.03.30
[백엔드] 운영 체제 2  (0) 2023.03.30
[백엔드] 운영 체제 1  (0) 2023.03.30

댓글