Perl 스레드 튜토리얼
Perl 스레드 튜토리얼 (perlthrtut)
Perl의 인터프리터 스레드(ithreads라고도 불러요) 사용법을 다루는 튜토리얼이에요. 이 모델에서 각 스레드는 자기만의 Perl 인터프리터에서 돌고, 스레드 사이의 데이터 공유는 반드시 명시적이어야 해요. 사용자 레벨 인터페이스는 threads 클래스예요.
주의: 예전에 5.005 모델이라 불리는 Thread 클래스를 쓰는 더 오래된 Perl 스레드 방식이 있었어요. 이 옛 모델은 문제가 있다고 알려졌고 deprecated됐으며 5.10 릴리스에서 제거됐어요. 기존 5.005 스레드 코드는 가능한 한 빨리 새 모델로 옮기는 걸 강력히 권장해요.
perl -V를 실행해 Platform 섹션을 보면 어떤 스레드 방식을 갖고 있는지(또는 둘 다 아닌지) 알 수 있어요. useithreads=define이면 ithreads, use5005threads=define이면 5.005 스레드예요. 둘 다 없으면 스레드 지원이 빌드되지 않은 거고, 둘 다 있으면 곤란한 거예요.
요즘에는 ithreads와 threads라는 용어가 동의어로 서로 바꿔 쓰여요. 가장 좋은 관행은: 코드가 그 구현의 특정한 점에 의존한다면 useithreads를, 아니다면 usethreads를 쓰는 거예요. 그렇게 하면 드물게라도 새 스레드 모델이 나와도 코드가 바뀔 필요에서 더 보호되요. (C 언어 레벨의 대응 심볼은 USE_ITHREADS와 USE_THREADS인데, 현재는 완전히 동의어지만 영원히 그렇다는 보장은 없어요.)
threads와 threads::shared 모듈은 Perl 코어 배포본에 포함돼 있어요. 또한 CPAN에서 별도 모듈로 유지보수되므로 업데이트를 거기서 확인할 수 있어요.
어쨌든 스레드란 무엇인가? (What Is A Thread Anyway?)
스레드는 프로그램을 통과하는 단일 실행 지점을 가진 제어 흐름이에요.
프로세스랑 아주 비슷하게 들리죠? 당연해요. 스레드는 프로세스의 한 조각이에요. 모든 프로세스에는 스레드가 적어도 하나 있고, 지금까지 Perl을 실행하는 모든 프로세스는 스레드가 하나뿐이었어요. 하지만 5.8부터 추가 스레드를 만들 수 있어요. 어떻게, 언제, 왜 만드는지 보여드릴게요.
스레드 프로그램 모델 (Threaded Program Models)
스레드 프로그램을 구조화하는 기본 방법은 세 가지가 있어요. 어떤 모델을 고르느냐는 프로그램이 무엇을 해야 하느냐에 달려 있어요. 많은 비자명한 스레드 프로그램에서 프로그램의 서로 다른 조각에 서로 다른 모델을 골라야 해요.
보스/워커 (Boss/Worker)
보스/워커 모델은 보통 보스 스레드 하나와 워커 스레드 하나 이상을 가져요. 보스 스레드가 해야 할 작업을 모으거나 생성한 다음, 그 작업을 적절한 워커 스레드에 나눠줘요.
이 모델은 GUI와 서버 프로그램에서 흔해요. 메인 스레드가 어떤 이벤트를 기다렸다가 그 이벤트를 처리를 위해 적절한 워커 스레드에 넘기는 형태예요. 이벤트를 넘기고 나면 보스 스레드는 다른 이벤트를 기다리는 상태로 돌아가요.
보스 스레드는 상대적으로 일을 적게 해요. 작업이 다른 방법보다 반드시 빠르게 수행되는 건 아니지만, 사용자 응답 시간이 가장 좋은 경향이 있어요.
워크 크루 (Work Crew)
워크 크루 모델에서는 여러 스레드를 만들어 서로 다른 데이터 조각에 본질적으로 같은 일을 해요. 대규모 프로세서 배열이 많은 데이터 조각에 정확히 같은 일을 하는 고전적 병렬 처리·벡터 프로세서를 가까이 비추죠.
이 모델은 프로그램을 실행하는 시스템이 여러 프로세서에 다중 스레드를 분산할 수 있을 때 특히 유용해요. 레이 트레이싱이나 렌더링 엔진에서도 유용한데, 개별 스레드가 중간 결과를 넘겨 사용자에게 시각적 피드백을 주거든요.
파이프라인 (Pipeline)
파이프라인 모델은 작업을 일련의 단계로 나누고, 한 단계의 결과를 다음 단계를 처리하는 스레드에 넘겨요. 각 스레드는 각 데이터 조각에 한 가지 일을 하고 결과를 줄의 다음 스레드에 넘겨요.
이 모델은 여러 프로세서가 있어 두 개 이상의 스레드가 병렬로 실행될 때 가장 말이 되지만, 다른 맥락에서도 자주 말이 돼요. 개별 작업을 작고 단순하게 유지하고, 파이프라인의 어떤 부분이(I/O나 시스템 호출 같은 데서) 블로킹되는 동안 다른 부분은 계속 진행되게 해요. 파이프라인의 서로 다른 부분을 서로 다른 프로세서에서 실행한다면 각 프로세서의 캐시도 활용할 수 있어요.
이 모델은 재귀적 프로그래밍의 한 형태에도 유용한데, 서브루틴이 자기 자신을 호출하는 대신 다른 스레드를 만드는 방식이에요. 소수(prime) 생성기와 피보나치 생성기가 모두 이 파이프라인 형태에 잘 맞아요. (소수 생성기 버전은 나중에 나와요.)
Perl 스레드는 어떤 종류의 스레드인가? (What kind of threads are Perl threads?)
다른 스레드 구현 경험이 있다면, 사물이 기대만큼 같지 않다는 걸 알게 될 거예요. Perl 스레드를 다룰 때 매우 중요한 건 Perl 스레드는 모든 X 값에 대해 X 스레드가 아니다라는 거예요. POSIX 스레드도, DecThreads도, Java의 Green 스레드도, Win32 스레드도 아니에요. 유사점이 있고 광범위한 개념은 같지만, 구현 세부사항을 찾기 시작하면 실망하거나 혼란스러울 거예요. 어쩌면 둘 다겠죠.
이것은 Perl 스레드가 이전의 모든 것과 완전히 다르다는 뜻은 아니에요. Perl의 스레드 모델은 다른 스레드 모델, 특히 POSIX에 많이 빚지고 있어요. Perl이 C가 아니듯, Perl 스레드는 POSIX 스레드가 아니에요. 그래서 mutex나 스레드 우선순위를 찾고 있다면, 한 걸음 물러서서 무엇을 하고 싶은지, Perl이 그것을 어떻게 할 수 있는지를 생각할 때예요.
하지만 운영 체제의 스레드가 허용하지 않으면 Perl 스레드가 마법처럼 뭔가를 할 수 없다는 걸 기억하는 게 중요해요. 시스템이 sleep()에서 전체 프로세스를 블록한다면 Perl도 대개 그럴 거예요.
Perl 스레드는 다르다. (Perl Threads Are Different.)
스레드 안전 모듈 (Thread-Safe Modules)
스레드 추가는 Perl의 내부를 상당히 바꿨어요. XS 코드나 외부 라이브러리를 쓰는 모듈을 작성하는 사람에게 함의가 있어요. 하지만 Perl 데이터는 기본적으로 스레드 간에 공유되지 않으므로, Perl 모듈은 스레드 안전일 가능성이 높거나 쉽게 안전하게 만들 수 있어요. 스레드 안전으로 태그되지 않은 모듈은 프로덕션 코드에서 쓰기 전에 테스트하거나 코드 리뷰해야 해요.
쓸지도 모르는 모든 모듈이 스레드 안전한 건 아니에요. 문서가 다르게 말하지 않으면 모듈을 항상 안전하지 않다고 가정해야 해요. 코어 일부로 배포되는 모듈을 포함해서요. 스레드는 비교적 새로운 기능이라 표준 모듈 중 일부조차 스레드 안전하지 않아요.
모듈이 스레드 안전해도 스레드와 잘 동작하도록 최적화됐다는 뜻은 아니에요. 모듈이 스레드된 Perl의 새 기능을 활용하도록 재작성되어 스레드 환경에서 성능을 높일 수도 있어요.
어떤 이유로 스레드 안전하지 않은 모듈을 쓰고 있다면, 그 모듈을 한 번에 한 스레드에서만 사용해 자신을 보호할 수 있어요. 여러 스레드가 그런 모듈에 접근해야 한다면 세마포어와 많은 프로그래밍 규율로 접근을 제어할 수 있어요. 세마포어는 "기본 세마포어"에서 다뤄요.
또한 "시스템 라이브러리의 스레드 안전성"도 참고하세요.
스레드 기초 (Thread Basics)
threads 모듈은 스레드 프로그램을 쓰는 데 필요한 기본 함수를 제공해요. 다음 절들에서 기초를 다루며 스레드 프로그램을 만드는 데 필요한 것을 보여줄게요. 그다음 스레드 프로그래밍을 쉽게 해주는 threads 모듈의 기능 몇 가지를 살펴보겠어요.
기본 스레드 지원 (Basic Thread Support)
스레드 지원은 Perl의 컴파일 타임 옵션이에요. 프로그램이 컴파일될 때가 아니라, Perl이 여러분 사이트에서 빌드될 때 켜지거나 꺼지는 거예요. Perl이 스레드 지원으로 컴파일되지 않았다면 스레드를 쓰려는 어떤 시도도 실패해요.
프로그램은 Config 모듈로 스레드가 켜졌는지 확인할 수 있어요. 스레드 없이는 프로그램이 못 돌아간다면:
use Config;
$Config{useithreads} or
die('Recompile Perl with threads to run this program.');
스레드됐을 수도 있는 모듈을 쓰는, 스레드됐을 수도 있는 프로그램은 이런 코드를 가질 수 있어요:
use Config;
use MyMod;
BEGIN {
if ($Config{useithreads}) {
# We have threads
require MyMod_threaded;
MyMod_threaded->import();
} else {
require MyMod_unthreaded;
MyMod_unthreaded->import();
}
}
스레드 유·무 둘 다에서 도는 코드는 대개 꽤 지저분하므로, 스레드 특유의 코드를 자기만의 모듈로 격리하는 게 최선이에요. 위 예에서 그게 MyMod_threaded인데, 스레드된 Perl에서 돌 때만 import돼요.
예제에 대한 주의 (A Note about the Examples)
실제 상황에서는 프로그램이 종료되기 전에 모든 스레드가 실행을 끝냈는지 주의해야 해요. 이 예제들에서는 단순함을 위해 그 주의를 기울이지 않았어요. 이 예제들을 그대로 실행하면, 대개 프로그램이 종료될 때 여전히 돌고 있는 스레드 때문에 에러 메시지가 나와요. 놀라지 마세요.
스레드 만들기 (Creating Threads)
threads 모듈은 새 스레드를 만드는 도구를 제공해요. 다른 모듈처럼 Perl에 쓰고 싶다고 알려야 해요. use threads;가 기본 스레드를 만드는 데 필요한 모든 조각을 import해요.
스레드를 만드는 가장 간단하고 직접적인 방법은 create()예요:
use threads;
my $thr = threads->create(\&sub1);
sub sub1 {
print("In the thread\n");
}
create() 메서드는 서브루틴에 대한 참조를 받아, 참조된 서브루틴에서 실행을 시작하는 새 스레드를 만들어요. 그러면 제어는 서브루틴과 호출자 양쪽으로 전달돼요.
필요하면 프로그램이 스레드 시작의 일부로 서브루틴에 매개변수를 넘길 수 있어요. threads->create() 호출의 일부로 매개변수 목록을 포함하면 돼요:
use threads;
my $Param3 = 'foo';
my $thr1 = threads->create(\&sub1, 'Param 1', 'Param 2', $Param3);
my @ParamList = (42, 'Hello', 3.14);
my $thr2 = threads->create(\&sub1, @ParamList);
my $thr3 = threads->create(\&sub1, qw(Param1 Param2 Param3));
sub sub1 {
my @InboundParameters = @_;
print("In the thread\n");
print('Got parameters >', join('<>',@InboundParameters), "<\n");
}
마지막 예는 스레드의 또 다른 기능을 보여줘요. 같은 서브루틴으로 여러 스레드를 생성할 수 있어요. 각 스레드는 같은 서브루틴을 실행하지만, 별개의 환경과 잠재적으로 별개의 인자를 가진 별개의 스레드에서요.
new()는 create()의 동의어예요.
스레드가 끝나길 기다리기 (Waiting For A Thread To Exit)
스레드는 서브루틴이기도 하므로 값을 반환할 수 있어요. 스레드가 끝나길 기다리고 반환할 값을 추출하려면 join() 메서드를 써요:
use threads;
my ($thr) = threads->create(\&sub1);
my @ReturnData = $thr->join();
print('Thread returned ', join(', ', @ReturnData), "\n");
sub sub1 { return ('Fifty-six', 'foo', 2); }
위 예에서 join() 메서드는 스레드가 끝나자마자 반환돼요. join()은 스레드가 끝나길 기다리고 스레드가 반환했을 값들을 모으는 것 외에, 그 스레드에 필요한 OS 정리도 수행해요. 그 정리는 특히 많은 스레드를 생성하는 장기 실행 프로그램에서 중요할 수 있어요. 반환값이 필요 없고 스레드가 끝나길 기다리고 싶지 않다면, 다음에 설명하는 것처럼 detach() 메서드를 호출해야 해요.
참고: 위 예에서 스레드는 목록을 반환하므로, 스레드 생성 호출을 리스트 컨텍스트(즉 my ($thr))에서 해야 해요. 스레드 컨텍스트와 반환값에 대한 더 자세한 내용은 threads의 "$thr->join()"과 "THREAD CONTEXT"를 참고하세요.
스레드 무시하기 (Ignoring A Thread)
join()은 세 가지를 해요: 스레드가 끝나길 기다리고, 그 뒤를 정리하며, 스레드가 만들어낸 데이터를 반환해요. 하지만 스레드의 반환값에 관심이 없고 스레드가 끝나는 때도 별로 신경 쓰지 않는다면? 그냥 스레드가 끝나면 정리되길 원하는 경우죠.
이때 detach() 메서드를 써요. 스레드가 detached되면 끝날 때까지 실행되고, Perl이 자동으로 정리해요.
use threads;
my $thr = threads->create(\&sub1); # Spawn the thread
$thr->detach(); # Now we officially don't care any more
sleep(15); # Let thread run for a while
sub sub1 {
my $count = 0;
while (1) {
$count++;
print("\$count is $count\n");
sleep(1);
}
}
일단 스레드가 detached되면 join할 수 없고, 만들어낸 반환 데이터(끝났고 join을 기다리고 있었다면)는 잃어요.
detach()는 클래스 메서드로도 호출할 수 있는데, 스레드가 스스로 detach되게 해줘요:
use threads;
my $thr = threads->create(\&sub1);
sub sub1 {
threads->detach();
# Do more work
}
프로세스와 스레드 종료 (Process and Thread Termination)
스레드를 쓸 때는 그것들이 모두(그게 원하는 것이라면) 끝까지 실행될 기회를 갖도록 주의해야 해요.
프로세스를 종료시키는 동작은 실행 중인 모든 스레드를 종료시켜요. die()와 exit()가 그런 성질을 갖고 있고, Perl은 메인 스레드가 끝나면 exit하는데, 어쩌면 코드 끝을 벗어나 암묵적으로 그럴 수도 있어요. 원하지 않더라도요.
이 경우의 예로, 이 코드는 "Perl exited with active threads: 2 running and unjoined" 메시지를 출력해요:
use threads;
my $thr1 = threads->new(\&thrsub, "test1");
my $thr2 = threads->new(\&thrsub, "test2");
sub thrsub {
my ($message) = @_;
sleep 1;
print "thread $message\n";
}
하지만 마지막에 다음 줄들이 추가되면:
$thr1->join();
$thr2->join();
아마 더 유용한 결과인 두 줄의 출력을 만들어요.
스레드와 데이터 (Threads And Data)
스레드의 기초를 다뤘으니 다음 주제인 데이터로 넘어갈게요. 스레딩은 데이터 접근에 몇 가지 복잡함을 도입하는데, 스레드 없는 프로그램은 신경 쓸 필요가 없었던 것들이에요.
공유 데이터와 비공유 데이터 (Shared And Unshared Data)
Perl ithreads와 옛 5.005 스타일 스레딩, 아니 대부분의 다른 스레딩 시스템의 가장 큰 차이는 기본적으로 어떤 데이터도 공유되지 않는다는 거예요. 새 Perl 스레드가 만들어지면 현재 스레드와 연관된 모든 데이터가 새 스레드로 복사되고, 이후 그 새 스레드에 비공개가 돼요! 이는 Unix 프로세스가 fork할 때 일어나는 일과 느낌이 비슷하지만, 이 경우 실제 fork가 일어나는 게 아니라 데이터가 같은 프로세스 안의 다른 메모리 부분으로 복사될 뿐이에요.
하지만 스레딩을 활용하려면 보통 스레드들이 서로 적어도 어떤 데이터는 공유하길 원해요. 이는 threads::shared 모듈과 :shared 속성으로 해요:
use threads;
use threads::shared;
my $foo :shared = 1;
my $bar = 1;
threads->create(sub { $foo++; $bar++; })->join();
print("$foo\n"); # Prints 2 since $foo is shared
print("$bar\n"); # Prints 1 since $bar is not shared
공유 배열의 경우 모든 배열 요소가 공유되고, 공유 해시의 경우 모든 키와 값이 공유돼요. 이는 공유 배열·해시 요소에 무엇이 할당될 수 있는지 제한을 둬요: 단순 값이나 공유 변수에 대한 참조만 허용돼요 — 그래야 비공개 변수가 실수로 공유되는 일이 없어요. 잘못된 할당은 스레드를 죽게 해요. 예를 들어:
use threads;
use threads::shared;
my $var = 1;
my $svar :shared = 2;
my %hash :shared;
... create some threads ...
$hash{a} = 1; # All threads see exists($hash{a})
# and $hash{a} == 1
$hash{a} = $var; # okay - copy-by-value: same effect as previous
$hash{a} = $svar; # okay - copy-by-value: same effect as previous
$hash{a} = \$svar; # okay - a reference to a shared variable
$hash{a} = \$var; # This will die
delete($hash{a}); # okay - all threads will see !exists($hash{a})
참고로 공유 변수는 두 개 이상의 스레드가 동시에 수정하려 할 때 변수의 내부 상태가 손상되지 않게 보장해요. 하지만 그 이상은 보장이 없어요. 다음 절에서 설명할게요.
스레드 함정: 경합 (Thread Pitfalls: Races)
스레드는 유용한 새 도구 묶음을 가져오지만 함정도 여럿 가져와요. 한 가지 함정은 **경합 조건(race condition)**이에요:
use threads;
use threads::shared;
my $x :shared = 1;
my $thr1 = threads->create(\&sub1);
my $thr2 = threads->create(\&sub2);
$thr1->join();
$thr2->join();
print("$x\n");
sub sub1 { my $foo = $x; $x = $foo + 1; }
sub sub2 { my $bar = $x; $x = $bar + 1; }
$x가 무엇이 될 것 같나요? 불행히도 답은 "그때그때 다르다"예요. sub1()과 sub2() 모두 전역 변수 $x에 한 번 읽고 한 번 쓰며 접근해요. 스레드 구현의 스케줄링 알고리즘부터 달의 위상까지 다양한 요인에 따라 $x는 2 또는 3이 돼요.
경합 조건은 공유 데이터에 대한 동기화되지 않은 접근 때문에 생겨요. 명시적 동기화 없이는, 공유 데이터에 접근한 시점과 갱신하는 시점 사이에 아무 일도 일어나지 않았다고 확신할 방법이 없어요. 이 단순한 코드 조각조차 오류 가능성이 있어요:
use threads;
my $x :shared = 2;
my $y :shared;
my $z :shared;
my $thr1 = threads->create(sub { $y = $x; $x = $y + 1; });
my $thr2 = threads->create(sub { $z = $x; $x = $z + 1; });
$thr1->join();
$thr2->join();
두 스레드 모두 $x에 접근해요. 각 스레드는 어떤 지점에서든 중단되거나 어떤 순서로든 실행될 수 있어요. 결국 $x는 3 또는 4가 될 수 있고, $y와 $z 둘 다 2 또는 3이 될 수 있어요.
$x += 5나 $x++조차 원자적임이 보장되지 않아요.
프로그램이 다른 스레드가 접근할 수 있는 데이터·자원에 접근할 때마다, 접근을 조정하는 조치를 해야 해요. 그렇지 않으면 데이터 불일치와 경합 조건의 위험을 안는 거예요. Perl은 자기 내부를 여러분의 경합 조건에서 보호하지만, 여러분을 여러분 자신으로부터는 보호하지 않아요.
동기화와 제어 (Synchronization and control)
Perl은 스레드들이 자기 자신과 자기 데이터 사이의 상호작용을 조정하고 경합 조건 같은 것을 피하는 여러 메커니즘을 제공해요. 일부는 pthreads 같은 스레드 라이브러리에서 쓰는 일반적인 기법을 닮도록 설계됐고, 다른 것들은 Perl 특유예요. 종종 표준 기법은 서툴고 제대로 하기 어려워요(조건 대기 같은 것). 가능하면 큐 같은 Perl스러운 기법을 쓰는 게 대개 더 쉬운데, 관련된 고된 작업의 일부를 없애주거든요.
접근 제어: lock() (Controlling access: lock())
lock() 함수는 공유 변수를 받아 그 위에 잠금을 걸어요. 잠금을 쥔 스레드가 변수를 잠금 해제할 때까지 다른 스레드는 그 변수를 잠글 수 없어요. 잠금 해제는 잠금을 쥔 스레드가 lock() 함수를 포함한 블록을 나갈 때 자동으로 일어나요. lock() 사용은 간단해요. 이 예는 여러 스레드가 병렬로 계산을 하고, 가끔 진행 중인 합계를 갱신해요:
use threads;
use threads::shared;
my $total :shared = 0;
sub calc {
while (1) {
my $result;
# (... do some calculations and set $result ...)
{
lock($total); # Block until we obtain the lock
$total += $result;
} # Lock implicitly released at end of scope
last if $result == 0;
}
}
my $thr1 = threads->create(\&calc);
my $thr2 = threads->create(\&calc);
my $thr3 = threads->create(\&calc);
$thr1->join();
$thr2->join();
$thr3->join();
print("total=$total\n");
lock()은 잠그는 변수가 사용 가능해질 때까지 스레드를 블록해요. lock()이 반환되면, 잠금을 포함한 블록이 끝날 때까지 다른 스레드가 그 변수를 잠글 수 없다고 확신할 수 있어요.
중요한 점은 잠금이 해당 변수에 대한 접근을 막지 않고 잠금 시도만 막는다는 거예요. 이는 Perl의 오랜 예의 바른 프로그래밍 전통과, flock()이 주는 권고적 파일 잠금과 일치해요.
스칼라뿐 아니라 배열과 해시도 잠글 수 있어요. 하지만 배열을 잠그는 건 배열 요소에 대한 이후의 잠금을 블록하지 않고, 배열 자체에 대한 잠금 시도만 블록해요.
잠금은 재귀적이에요. 스레드가 변수를 두 번 이상 잠그는 게 괜찮다는 뜻이에요. 잠금은 그 변수에 대한 가장 바깥쪽 lock()이 스코프를 벗어날 때까지 지속돼요. 예를 들어:
my $x :shared;
doit();
sub doit {
{
{
lock($x); # Wait for lock
lock($x); # NOOP - we already have the lock
{
lock($x); # NOOP
{
lock($x); # NOOP
lockit_some_more();
}
}
} # *** Implicit unlock here ***
}
}
sub lockit_some_more {
lock($x); # NOOP
} # Nothing happens here
참고로 unlock() 함수는 없어요 — 변수를 잠금 해제하는 유일한 방법은 스코프를 벗어나게 하는 거예요.
잠금은 잠그는 변수에 담긴 데이터를 지키거나, 코드의 한 부분 같은 다른 것을 지키는 데 쓸 수 있어요. 후자의 경우 해당 변수는 유용한 데이터를 담지 않고, 잠기기 위해 존재할 뿐이에요. 이런 점에서 그 변수는 전통적 스레드 라이브러리의 mutex와 기본 세마포어처럼 행동해요.
스레드 함정: 교착 (A Thread Pitfall: Deadlocks)
잠금은 데이터 접근을 동기화하는 편리한 도구이고, 제대로 쓰는 것이 안전한 공유 데이터의 핵심이에요. 불행히도 잠금은 위험도 있어요. 특히 여러 잠금이 관련될 때요. 다음 코드를 보세요:
use threads;
use threads::shared;
my $x :shared = 4;
my $y :shared = 'foo';
my $thr1 = threads->create(sub {
lock($x);
sleep(20);
lock($y);
});
my $thr2 = threads->create(sub {
lock($y);
sleep(20);
lock($x);
});
$thr1->join();
$thr2->join();
이 프로그램은 아마 죽일 때까지 멈춰 있을 거예요. 멈추지 않을 유일한 경우는 두 스레드 중 하나가 두 잠금을 먼저 획득하는 때예요. 확실히 멈추는 버전은 더 복잡하지만 원리는 같아요.
첫 스레드가 $x 잠금을 잡고, 잠시 멈춘 뒤(그 사이 두 번째 스레드가 일을 했을 가능성이 큼) $y 잠금을 시도해요. 한편 두 번째 스레드는 $y 잠금을 잡고 나중에 $x 잠금을 시도해요. 두 스레드의 두 번째 잠금 시도는 각자 다른 쪽이 잠금을 놓아주길 기다리며 블록돼요.
이 조건을 **교착(deadlock)**이라고 불러요. 두 개 이상의 스레드가 다른 쪽이 소유한 자원의 잠금을 얻으려 할 때마다 발생해요. 각 스레드는 다른 쪽이 자원 잠금을 놓아주길 기다리며 블록되는데, 자원을 가진 스레드 자신도 잠금 해제를 기다리고 있으므로 그런 일은 절대 일어나지 않아요.
이런 문제를 다루는 방법은 여러 가지가 있어요. 최선은 모든 스레드가 정확히 같은 순서로 잠금을 획득하게 하는 거예요. 예를 들어 $x, $y, $z를 잠근다면 항상 $x를 $y보다 먼저, $y를 $z보다 먼저 잠가요. 교착 위험을 최소화하려면 잠금을 가능한 한 짧게 잡고 있는 것도 최선이에요.
아래 설명할 다른 동기화 프리미티브도 비슷한 문제를 겪을 수 있어요.
큐: 데이터 돌려보내기 (Queues: Passing Data Around)
**큐(queue)**는 동기화 문제를 걱정할 필요 없이 한쪽 끝에 데이터를 넣고 다른 쪽에서 꺼낼 수 있게 해주는 특수한 스레드 안전 객체예요. 꽤 단순하고 이렇게 생겼어요:
use threads;
use Thread::Queue;
my $DataQueue = Thread::Queue->new();
my $thr = threads->create(sub {
while (my $DataElement = $DataQueue->dequeue()) {
print("Popped $DataElement off the queue\n");
}
});
$DataQueue->enqueue(12);
$DataQueue->enqueue("A", "B", "C");
sleep(10);
$DataQueue->enqueue(undef);
$thr->join();
Thread::Queue->new()로 큐를 만들어요. 그다음 enqueue()로 스칼라 목록을 끝에 추가하고, dequeue()로 앞에서 스칼라를 꺼낼 수 있어요. 큐는 고정 크기가 없고, 밀어 넣은 모든 것을 담도록 필요에 따라 자랄 수 있어요.
큐가 비어 있으면 dequeue()는 다른 스레드가 무언가 enqueue할 때까지 블록해요. 이는 큐를 이벤트 루프와 다른 스레드 간 통신에 이상적으로 만들어줘요.
세마포어: 데이터 접근 동기화 (Semaphores: Synchronizing Data Access)
세마포어는 일종의 일반적인 잠금 메커니즘이에요. 가장 기본적인 형태에서 잠글 수 있는 스칼라처럼 행동하지만, 데이터를 담을 수 없고 반드시 명시적으로 잠금 해제해야 해요. 고급 형태에서는 일종의 카운터처럼 행동해서, 한 번에 여러 스레드가 잠금을 갖게 할 수 있어요.
기본 세마포어 (Basic semaphores)
세마포어는 down()과 up() 두 메서드가 있어요. down()은 리소스 카운트를 줄이고, up()은 늘려요. 세마포어의 현재 카운트가 0 아래로 줄어들게 하면 down() 호출이 블록돼요. 이 프로그램이 빠른 시연이에요:
use threads;
use Thread::Semaphore;
my $semaphore = Thread::Semaphore->new();
my $GlobalVariable :shared = 0;
$thr1 = threads->create(\&sample_sub, 1);
$thr2 = threads->create(\&sample_sub, 2);
$thr3 = threads->create(\&sample_sub, 3);
sub sample_sub {
my $SubNumber = shift(@_);
my $TryCount = 10;
my $LocalCopy;
sleep(1);
while ($TryCount--) {
$semaphore->down();
$LocalCopy = $GlobalVariable;
print("$TryCount tries left for sub $SubNumber "
."(\$GlobalVariable is $GlobalVariable)\n");
sleep(2);
$LocalCopy++;
$GlobalVariable = $LocalCopy;
$semaphore->up();
}
}
$thr1->join();
$thr2->join();
$thr3->join();
서브루틴의 세 번의 호출이 모두 동기화되어 동작해요. 하지만 세마포어는 한 번에 한 스레드만 전역 변수에 접근하게 보장해요.
고급 세마포어 (Advanced Semaphores)
기본적으로 세마포어는 잠금처럼 행동해서 한 번에 한 스레드만 down()할 수 있게 해요. 하지만 세마포어에는 다른 용도도 있어요.
각 세마포어에는 카운터가 붙어 있어요. 기본적으로 세마포어는 카운터를 1로 설정해 만들어지고, down()은 카운터를 1 줄이고 up()은 1 늘려요. 하지만 다른 값을 전달해 이 기본값의 일부 또는 전부를 덮어쓸 수 있어요:
use threads;
use Thread::Semaphore;
my $semaphore = Thread::Semaphore->new(5);
# Creates a semaphore with the counter set to five
my $thr1 = threads->create(\&sub1);
my $thr2 = threads->create(\&sub1);
sub sub1 {
$semaphore->down(5); # Decrements the counter by five
# Do stuff here
$semaphore->up(5); # Increment the counter by five
}
$thr1->detach();
$thr2->detach();
down()이 카운터를 0 아래로 줄이려 하면 카운터가 충분히 커질 때까지 블록해요. 세마포어는 시작 카운트 0으로 만들 수 있지만, 어떤 up()·down()도 항상 카운터를 적어도 1 바꾸므로 $semaphore->down(0)은 $semaphore->down(1)과 같아요.
물론 질문은 "왜 이런 걸 할까?"예요. 왜 1이 아닌 시작 카운트로 세마포어를 만들까, 왜 1보다 많이 줄이거나 늘릴까? 답은 자원 가용성이에요. 접근을 관리하고 싶은 많은 자원은 한 번에 여러 스레드가 안전하게 쓸 수 있거든요.
예를 들어 GUI 구동 프로그램을 봐요. 디스플레이 접근을 동기화하는 세마포어를 써서, 한 번에 한 스레드만 그리게 해요. 편리하지만, 물론 모든 것이 제대로 설정될 때까지 어떤 스레드도 그리기 시작하는 걸 원하지 않아요. 이 경우 카운터 0으로 세마포어를 만들고, 그릴 준비가 됐을 때 up()하면 돼요.
카운터가 1보다 큰 세마포어는 할당량(quota)을 세우는 데도 유용해요. 예를 들어, 한 번에 I/O를 할 수 있는 스레드가 여러 개 있다고 해요. 하지만 모든 스레드가 동시에 읽거나 쓰는 걸 원하지는 않을 거예요. I/O 채널을 침수시키거나 프로세스의 파일핸들 할당량을 고갈시킬 수 있으니까요. 한 번에 원하는 동시 I/O 요청(또는 열린 파일) 수로 초기화된 세마포어를 쓰고, 스레드들이 조용히 블록·해제하게 할 수 있어요.
더 큰 증가·감소는 스레드가 한 번에 여러 자원을 체크아웃하거나 반환해야 할 때 유용해요.
조건 기다리기 (Waiting for a Condition)
cond_wait()와 cond_signal() 함수는 잠금과 함께 써서 협력하는 스레드에게 자원이 사용 가능해졌음을 알릴 수 있어요. 사용법은 pthreads의 함수와 매우 비슷해요. 하지만 대부분의 경우 큐가 더 단순하고 직관적이에요. 자세한 내용은 threads::shared를 참고하세요.
제어 넘겨주기 (Giving up control)
때로 스레드가 CPU를 다른 스레드에게 명시적으로 넘겨주는 게 유용할 때가 있어요. 프로세서 집약적인 일을 하면서 사용자 인터페이스 스레드가 자주 호출되게 하고 싶을 수도 있어요. 어쨌든 스레드가 프로세서를 넘겨주길 원하는 때가 있어요.
Perl의 스레딩 패키지는 yield() 함수를 제공해요. yield()는 꽤 단순하고 이렇게 동작해요:
use threads;
sub loop {
my $thread = shift;
my $foo = 50;
while($foo--) { print("In thread $thread\n"); }
threads->yield();
$foo = 50;
while($foo--) { print("In thread $thread\n"); }
}
my $thr1 = threads->create(\&loop, 'first');
my $thr2 = threads->create(\&loop, 'second');
my $thr3 = threads->create(\&loop, 'third');
중요한 점은 yield()는 CPU를 넘겨주라는 힌트일 뿐이라는 거예요. 실제로 무슨 일이 일어나는지는 하드웨어·OS·스레드 라이브러리에 달려 있어요. 많은 운영 체제에서 yield()는 no-op이에요. 따라서 yield() 호출을 중심으로 스레드의 스케줄링을 세워선 안 된다는 점을 유의하세요. 여러분 플랫폼에서는 동작할지 몰라도 다른 플랫폼에서는 안 돼요.
일반 스레드 유틸리티 루틴 (General Thread Utility Routines)
Perl 스레딩 패키지의 핵심 부분들을 다뤘고, 이 도구들로 스레드 코드와 패키지를 쓰는 길에 잘 올라서 있을 거예요. 다른 어디에도 딱 안 맞는 유용한 작은 조각들이 몇 가지 있어요.
내가 어떤 스레드에 있지? (What Thread Am I In?)
threads->self() 클래스 메서드는 프로그램이 현재 있는 스레드를 나타내는 객체를 얻는 방법을 제공해요. 스레드 생성에서 반환된 것들과 같은 방식으로 이 객체를 쓸 수 있어요.
스레드 ID (Thread IDs)
tid()는 객체가 나타내는 스레드의 스레드 ID를 반환하는 스레드 객체 메서드예요. 스레드 ID는 정수이고, 프로그램의 메인 스레드는 0이에요. 현재 Perl은 프로그램에서 만들어진 모든 스레드에 고유 TID를 할당하는데, 첫 번째로 만들어진 스레드에 TID 1을 주고 새 스레드가 만들어질 때마다 1씩 늘려요. 클래스 메서드로 쓰면 threads->tid()로 스레드가 자신의 TID를 얻을 수 있어요.
이 스레드들 같은 건가? (Are These Threads The Same?)
equal() 메서드는 두 스레드 객체를 받아 객체들이 같은 스레드를 나타내면 참을, 아니면 거짓을 반환해요.
스레드 객체는 일반 객체처럼 비교할 수 있게 == 비교도 오버로드돼 있어요.
어떤 스레드들이 실행 중이지? (What Threads Are Running?)
threads->list()는 현재 실행 중이고 detached되지 않은 각 스레드에 대해 하나씩, 스레드 객체의 목록을 반환해요. 프로그램 끝에서 정리(물론 메인 Perl 스레드에서)하는 것 포함 여러 일에 편리해요:
# Loop through all the threads
foreach my $thr (threads->list()) {
$thr->join();
}
메인 Perl 스레드가 끝날 때 일부 스레드가 아직 끝나지 않았다면 Perl이 경고하고 die해요. 다른 스레드가 도는 동안 Perl이 스스로를 정리하는 건 불가능하기 때문이에요.
참고: 메인 Perl 스레드(스레드 0)는 detached 상태라 threads->list()가 반환하는 목록에 나타나지 않아요.
완전한 예제 (A Complete Example)
아직 혼란스러운가요? 다룬 것들 중 일부를 보여주는 예제 프로그램을 볼 때예요. 이 프로그램은 스레드로 소수를 찾아요.
1 #!/usr/bin/perl
2 # prime-pthread, courtesy of Tom Christiansen
3
4 use v5.36;
5
6 use threads;
7 use Thread::Queue;
8
9 sub check_num ($upstream, $cur_prime) {
10 my $kid;
11 my $downstream = Thread::Queue->new();
12 while (my $num = $upstream->dequeue()) {
13 next unless ($num % $cur_prime);
14 if ($kid) {
15 $downstream->enqueue($num);
16 } else {
17 print("Found prime: $num\n");
18 $kid = threads->create(\&check_num, $downstream, $num);
19 if (! $kid) {
20 warn("Sorry. Ran out of threads.\n");
21 last;
22 }
23 }
24 }
25 if ($kid) {
26 $downstream->enqueue(undef);
27 $kid->join();
28 }
29 }
30
31 my $stream = Thread::Queue->new(3..1000, undef);
32 check_num($stream, 2);
이 프로그램은 파이프라인 모델로 소수를 생성해요. 파이프라인의 각 스레드는 검사할 숫자를 공급하는 입력 큐, 책임질 소수, 검사에 실패한 숫자를 흘려보내는 출력 큐를 가져요. 스레드가 검사에 실패했지만 자식 스레드가 없는 숫자를 가졌다면, 그 스레드가 새 소수를 찾은 거예요. 그 경우 그 소수에 대한 새 자식 스레드가 만들어져 파이프라인 끝에 붙어요.
이건 실제보다 더 혼란스러워 들릴지도 몰라요. 프로그램을 조각조각 살펴보고 무슨 일을 하는지 봐요. (소수가 정확히 뭔지 기억하려 애쓰는 분들 위해: 자기 자신과 1로만 나누어떨어지는 숫자예요.)
일의 대부분은 check_num() 서브루틴이 해요. 이 서브루틴은 입력 큐에 대한 참조와 책임질 소수를 받아요. 새 큐를 만들고(11행) 나중에 만들 스레드를 위해 스칼라를 예약해요(10행).
12~24행의 while 루프는 입력 큐에서 스칼라를 잡아 이 스레드가 책임지는 소수에 대해 검사해요. 13행은 검사할 숫자를 우리 소수로 나눴을 때 나머지가 있는지 봐요. 나머지가 있으면 그 숫자는 우리 소수로 나누어떨어지지 않는다는 뜻이므로, 자식을 만들었다면 다음 스레드로 넘기거나(15행), 만들지 않았다면 새 스레드를 만들어야 해요.
새 스레드 생성은 18행이에요. 우리가 만든 큐에 대한 참조와 찾은 소수를 넘겨요. 19~22행에서 새 스레드가 만들어졌는지 확인하고, 아니라면 큐의 남은 숫자 검사를 멈춰요.
마지막으로 루프가 끝나면(큐에서 0이나 undef를 받아 종료 신호 역할을 하기 때문에), 자식을 만들었다면 그 통지를 자식에게 넘기고 기다려요(25·28행).
한편 메인 스레드로 돌아와서, 먼저 큐를 만들고(31행) 3부터 1000까지의 모든 숫자와 종료 통지를 큐에 넣어요. 그다음 큐와 첫 소수를 check_num() 서브루틴에 넘겨 일을 시작하기만 하면 돼요(32행).
그렇게 동작해요. 꽤 단순해요. 많은 Perl 프로그램처럼 설명이 프로그램보다 훨씬 길어요.
스레드의 다양한 구현 (Different implementations of threads)
운영 체제 관점에서 스레드 구현에 대한 배경 지식이에요. 스레드에는 세 가지 기본 범주가 있어요: 사용자 모드 스레드, 커널 스레드, 멀티프로세서 커널 스레드.
사용자 모드 스레드는 프로그램과 그 라이브러리 안에 완전히 존재하는 스레드예요. 이 모델에서 OS는 스레드에 대해 아무것도 몰라요. 그것의 관점에서 여러분 프로세스는 그냥 프로세스예요.
이것은 스레드를 구현하는 가장 쉬운 방법이고, 대부분의 OS가 시작하는 방식이에요. 큰 단점은 OS가 스레드를 모르므로, 한 스레드가 블록되면 모두 블록된다는 거예요. 전형적인 블로킹 활동에는 대부분의 시스템 호출, 대부분의 I/O, sleep() 같은 것이 포함돼요.
커널 스레드는 스레드 진화의 다음 단계예요. OS는 커널 스레드를 알고, 그것을 위한 고려를 해요. 커널 스레드와 사용자 모드 스레드의 주요 차이는 블로킹이에요. 커널 스레드에서는 단일 스레드를 블록하는 것들이 다른 스레드를 블록하지 않아요. 사용자 모드 스레드에서는 커널이 스레드 레벨이 아니라 프로세스 레벨에서 블록하므로 그렇지 않아요.
이것은 큰 진전이고, 스레드된 프로그램에 스레드 없는 프로그램보다 꽤 큰 성능 향상을 줄 수 있어요. 예를 들어 I/O를 수행하며 블록하는 스레드는 다른 일을 하는 스레드를 블록하지 않아요. 하지만 각 프로세스는 시스템이 CPU를 몇 개 갖든 여전히 한 번에 한 스레드만 실행해요.
커널 스레딩은 언제든 스레드를 중단할 수 있으므로, 프로그램에서 암묵적으로 가정할 수 있는 잠금 가정 일부를 드러내요. 예를 들어 $x = $x + 2 같은 단순한 것조차, $x가 다른 스레드에 보인다면 커널 스레드에서 예측 불가능하게 행동할 수 있어요. 다른 스레드가 우변에서 가져간 시점과 새 값이 저장되는 시점 사이에 $x를 바꿨을 수 있으니까요.
멀티프로세서 커널 스레드는 스레드 지원의 마지막 단계예요. 멀티 CPU 머신의 멀티프로세서 커널 스레드에서 OS는 두 개 이상의 스레드를 서로 다른 CPU에서 동시에 실행하도록 스케줄할 수 있어요.
이것은 둘 이상의 스레드가 동시에 실행되므로 스레드된 프로그램에 심각한 성능 향상을 줄 수 있어요. 대가로, 기본 커널 스레드에서 나타나지 않았을 골치 아픈 동기화 이슈가 엄청나게 나타날 거예요.
스레드에 대한 OS 참여 수준이 다른 것 외에도, 다른 OS(그리고 특정 OS의 다른 스레드 구현)는 CPU 주기를 스레드에 다양한 방식으로 배분해요.
협동 멀티태스킹(cooperative multitasking) 시스템은 두 가지 중 하나가 일어나면 실행 중인 스레드가 제어를 넘겨줘요. 스레드가 yield 함수를 호출하면 제어를 넘겨줘요. I/O 수행처럼 블록하게 만드는 일을 해도 제어를 넘겨줘요. 협동 멀티태스킹 구현에서 한 스레드가 원하면 다른 모든 스레드를 CPU 시간에서 굶길 수 있어요.
선점 멀티태스킹(preemptive multitasking) 시스템은 어떤 스레드가 다음에 실행될지 시스템이 결정하는 동안 정기적으로 스레드를 중단해요. 선점 멀티태스킹 시스템에서 한 스레드가 보통 CPU를 독점하지 않아요.
일부 시스템에서는 협동·선점 스레드가 동시에 실행될 수 있어요. (예를 들어 실시간 우선순위로 도는 스레드는 종종 협동적으로, 정상 우선순위로 도는 스레드는 선점적으로 행동해요.)
요즘 대부분의 현대 OS는 선점 멀티태스킹을 지원해요.
성능 고려 사항 (Performance considerations)
Perl의 ithreads를 다른 스레딩 모델과 비교할 때 염두에 둘 핵심은, 새 스레드마다 부모 스레드의 모든 변수와 데이터의 완전한 복사본을 가져야 한다는 사실이에요. 그래서 스레드 생성은 메모리 사용과 생성 시간 둘 다에서 꽤 비쌀 수 있어요. 이런 비용을 줄이는 이상적 방법은 수명이 긴 스레드를 비교적 적게 갖고, 모두 꽤 일찍(기본 스레드가 데이터를 너무 많이 축적하기 전에) 만드는 거예요. 물론 항상 가능한 건 아니라 타협해야 해요. 하지만 스레드가 만들어진 후에는 그 성능과 추가 메모리 사용이 일반 코드와 거의 다르지 않을 거예요.
또한 현재 구현에서 공유 변수는 일반 변수보다 메모리를 조금 더 쓰고 조금 더 느려요.
프로세스 범위 변경 (Process-scope Changes)
스레드 자체는 별개의 실행 스레드이고 Perl 데이터는 명시적으로 공유하지 않으면 스레드-비공개지만, 스레드는 프로세스 범위 상태에 영향을 줘 모든 스레드에 영향을 줄 수 있다는 걸 유의하세요.
가장 흔한 예는 chdir()로 현재 작업 디렉터리를 바꾸는 거예요. 한 스레드가 chdir()을 호출하면 모든 스레드의 작업 디렉터리가 바뀌어요.
훨씬 더 극단적인 프로세스 범위 변경 예는 chroot()이에요. 모든 스레드의 루트 디렉터리가 바뀌고, 어떤 스레드도(chdir()과 달리) 되돌릴 수 없어요.
프로세스 범위 변경의 추가 예로는 umask()와 uid·gid 변경이 있어요.
fork()과 스레드를 섞는 걸 생각 중인가요? 누워서 그 감정이 지나가길 기다리세요. fork()의 의미는 플랫폼마다 다르다는 걸 알아두세요. 예를 들어 일부 Unix 시스템은 현재 모든 스레드를 자식 프로세스로 복사하지만, 다른 시스템은 fork()를 호출한 스레드만 복사해요. 경고했어요!
마찬가지로 시그널과 스레드를 섞는 것도 문제가 될 수 있어요. 구현은 플랫폼에 의존하고, 심지어 POSIX 의미도 기대와 다를 수 있어요(그리고 Perl은 완전한 POSIX API조차 주지 않아요). 예를 들어 멀티스레드 Perl 애플리케이션에 보낸 시그널이 특정 스레드에 가로채진다는 보장은 없어요. (하지만 최근 추가된 기능이 스레드 간 시그널 전송을 가능하게 해줘요. 자세한 내용은 threads의 "THREAD SIGNALLING" 참고.)
시스템 라이브러리의 스레드 안전성 (Thread-Safety of System Libraries)
C 라이브러리 호출이 스레드 안전한지는 Perl의 통제 밖이에요. 멀티스레드 작업 중 안전하지 않은 것을 쓰면 정의되지 않은 동작이 일어나요. perlclib의 "Dealing with embedded perls and threads"를 참고하세요.
결론 (Conclusion)
완전한 스레드 튜토리얼은 책 한 권을 채울 수 있어요(그리고 여러 번 그랬어요). 하지만 이 입문에서 다룬 것으로 여러분은 스레드된 Perl 전문가가 되는 길에 잘 올라서 있어요.
함께 보기 (SEE ALSO)
- threads 주석이 달린 POD: https://web.archive.org/web/20171028020148/http://annocpan.org/?mode=search&field=Module&name=threads
- CPAN의 최신 threads: https://metacpan.org/pod/threads
- threads::shared 주석 POD: https://web.archive.org/web/20171028020148/http://annocpan.org/?mode=search&field=Module&name=threads%3A%3Ashared
- CPAN의 최신 threads::shared: https://metacpan.org/pod/threads::shared
- Perl threads 메일링 리스트: https://lists.perl.org/list/ithreads.html
더 알아보기 (Learn more)
threads— 스레드 생성·제어 모듈 레퍼런스threads::shared— 데이터 공유Thread::Queue,Thread::Semaphore— 동기화 프리미티브