다음 이전 차례

2. SMP 리눅스

이 문서는 병렬처리를 위해 SMP 리눅스 시스템을 어떻게 사용할 수 있는지에 관해 간단하게 개요를 제시한다. SMP 리눅스에 대한 가장 최근 정보는 아마도 SMP 리눅스 프로젝트의 메일링 리스트에서 얻을 수 있을 것이다. 이 리스트에 가입하려면 편지 본문에 subscribe linux-smp 라고 적어 majordomo@vger.rutgers.edu로 편지를 보내면 된다.

SMP 리눅스가 정말 제대로 동작하는가? 1996년 6월, 나는 새로운 상표의 (사실은 한물 간 품종이었지만 새 상표였다 ;-) 두개의 100MHz 펜티엄 프로세서를 가지는 시스템을 구입했다. 조립을 마친 시스템은 두개의 프로세서와 Asus 마더보더(motherboard), 256K 캐시, 32M RAM, 1.66G 하드디스크, 6배속 CDROM, Stealth 64 그래픽 카드와 15인치 모니터로, 이를 마련하는데 모두 1800$가 들었다. 이 가격은 이와 비슷한 사양의 프로세서 하나인 시스템보다 단지 몇백 달러정도 비싼 거였다. 제대로 동작하는 SMP 리눅스를 구할려면, 그저 보통의 단일 프로세서 리눅스를 설치하고, makefile에서 SMP=1을 막고 있는 주석을 해제하여 (비록 SMP1로 설정하는 것이 조금은 반어적이라는 것을 알지만) 커널을 다시 컴파일하고, lilo에게 새로운 커널을 알려주기만 하면 된다. 이 시스템은 매우 잘 동작하였고, 안정적이기도 하여, 지금까지 사용해온 나의 주 워크스테이션의 역할을 수행하기에 충분했다. 요약하면, SMP 리눅스는 정말로 제대로 동작한다.

다음 질문은 SMP 리눅스가 공유 메모리를 사용하는 병렬 프로그램을 작성하고 실행하는 데 있어 얼만큼이나 고수준으로 지원을 해주느냐이다. 1996년 초에는 이런 것은 별로 많지 않았다. 그러나 이제 많은 것이 변했다. 예를 들어, 이제는 매우 완벽한 POSIX 쓰레드(thread) 라이브러리가 있다.

공유 메모리 방식을 사용하는 것보다는 성능이 떨어질 수도 있지만, SMP 리눅스 시스템에서는 원래 소켓 통신을 사용하는 워크스테이션 클러스터(cluster)에서 동작하도록 개발된 대부분의 병렬 처리 소프트웨어도 사용할 수 있다. 소켓(3.3 장을 보라) 방식은 SMP 리눅스 시스템 뿐만 아니라, 여러개의 SMP 시스템을 네트웍으로 연결한 클러스터에서도 사용할 수 있다. 그렇지만 소켓은 SMP에서는 상당한 양의 불필요한 오버헤드(overhead)를 가지게 된다. 이런 오버헤드의 대부분은 커널 즉 인터럽트 핸들러에서 일어난다. SMP 리눅스에서는 보통 동시에 하나의 프로세서만이 커널 모드에 있을 수 있고, 부트 프로세서만이 인터럽트를 처리할 수 있도록 인터럽트 컨트롤러가 설정되어 있기 때문에 문제는 더 심각해진다. 그럼에도 불구하고, 전형적인 SMP 통신 하드웨어가 클러스터 네트웍보다는 훨씬 좋기 때문에, 원래 클러스터에서 사용하려고 만든 클러스터용 소프트웨어도 SMP에서 더 좋은 성능을 보인다.

이 장의 나머지에서는 SMP 하드웨어에 대해서 이야기하고, 병렬 프로그램 프로세스 사이에서 메모리를 공유하는 기본적인 리눅스 메커니즘을 살펴보고, 원자성(atomicity), 휘발성(volatility), 락(lock), 캐시 라인(cache line)에 대해서 아주 간단하게 알아보고, 마지막으로 여러가지 공유 메모리 병렬 처리 라이브러리들에 대해 약간의 조언을 하도록 한다.

2.1 SMP 하드웨어(Hardware)

SMP 시스템들은 여러해 전부터 사용되어 왔지만, 얼마전까지만해도 기계마다 기본적인 기능들을 서로 다르게 구현하는 경향이 있어서, 운영체제에서 SMP를 지원하는 것이 호환성이 없었다. 이런 문제를 종식시킨 것은 인텔에서 발표한 다중프로세서 규약(Multiprocessor Specification, 간단히 줄여서 MPS라고 한다)이다. MPS 1.4 규약은 http://www.intel.com/design/pro/datashts/242016.htm에서 PDF 파일 형식으로 된 문서로 구할 수 있으며, http://support.intel.com/oem_developer/ial/support/9300.HTM에서 MPS 1.1 규약에 대한 개요를 볼 수 있지만, 인텔이 자신의 WWW 사이트를 종종 개편을 하기 때문에 이 주소는 바뀌었을 수도 있다. 많은 제작들은 4개의 프로세서까지 지원하는 MPS 호환 시스템들을 만들고 있지만, 이론적으로 MPS는 더 많은 프로세서들을 지원할 수 있다.

MPS가 아니면서 IA32(인텔 32비트 CPU)가 아닌 시스템 중에서 SMP 리눅스가 지원하는 시스템으로는 Sun4m 다중프로세서 SPARC 시스템이 유일하다. SMP 리눅스는 인텔 MPS 1.1과 1.4 호환 시스템을 대부분 지원하며, 16개까지의 486DX, Pentium, Pentium MMX, Pentium Pro, Pentium II 프로세서를 지원한다. 지원하지 않는 IA32 프로세서로는 인텔 386, 486SX/SLC 프로세서와 (부동 소숫점 연산 하드웨어가 없으면 SMP 기계에 맞지 않는다), AMD와 Cyrix의 프로세서들이다 (이들 프로세서는 다른 SMP 지원 칩들을 필요로 하는데, 이 글을 쓰고 있을 때 아직 이들 칩들은 나와있지 않았다).

MPS 호환 시스템들의 성능은 천차만별로 달라질 수 있다는 점은 꼭 이해하고 넘어가야 한다. 일반적인 예상대로 성능의 차이를 나타내는 요인 중의 하나는 프로세서 속도이다. 대체로 좀 더 빠른 클럭의 프로세서을 사용하면 더 빠른 시스템이 되며, Pentium Pro 프로세서를 사용한 시스템이 Pentium 프로세서를 이용하는 시스템보다 빠른 경향이 있다. 그렇지만 MPS에서는 공유 메모리(shared memory)를 하드웨어적으로 어떻게 구현해야 하는지는 명시하지 않고 있다. 단지 소프트웨어적인 관점에서 공유 메모리가 어떻게 동작해야 하는지만 명시하고 있을 뿐이다. 그래서 구현하고 있는 공유 메모리 방식이 SMP 리눅스의 특징과 특정 프로그램의 특징에 어떻게 맞아들어가느냐에 따라서 성능이 달라질 수 있다.

MPS 호환 시스템들의 차이는 우선 물리적으로 공유 메모리에 접근하는 것을 어떻게 구현하느냐에서 나타난다.

각 프로세서가 독자적인 L2 캐시를 가지는가?(Does each processor have its own L2 cache?)

일부 MPS Pentium 시스템과, 모든 MPS Pentium Pro와 Pentium II 시스템은 독자적인 L2 캐시를 가지고 있다. (L2 캐시는 Pentium Pro나 Pentium II 모듈에 들어있다) 일반적으로 독자적인 L2 캐시를 사용하면 처리 속도를 최대화할 수 있다고 알려져 있지만, 리눅스에서는 명백하게 그런 것은 아니다. 이를 혼란하게 하는 주된 이유는, 현재의 리눅스 스케줄러가 각 프로세스를 똑같은 프로세서에서 실행되게 하는 프로세서 친화력(processor infinity)의 개념을 따르지는 않기 때문이다. 프로세스가 실행되는 프로세서는 금방 바뀔수 있다. 이 문제는 최근에 "프로세서 결합(processor binding)"이라는 제목으로 SMP 리눅스 개발 공동체에서 토론된 적이 있다. 프로세서 친화력 없이 별도의 L2 캐시를 갖게되면, 어떤 프로세스가 이전에 실행되던 프로세서가 아닌 다른 프로세서에서 시간을 할당받아 실행되는 경우 상당한 오버헤드를 초래할 수 있다.

상대적으로 값이 싼 상당수의 시스템들은 두개의 Pentium 프로세서가 하나의 L2 캐시를 공유하도록 만들어져 있다. 이 방식의 안좋은 점은 두개의 프로세서가 서로 캐시를 사이에 두고 경쟁을 해야한다는 것으로, 특히 여러개의 서로 독립적인 프로그램을 실행하는 경우 성능이 현저하게 떨어진다는 것이다. 좋은 점은 많은 병렬 프로그램들에게 있어서 두 개의 프로세서가 공유 메모리의 똑같은 라인에 접근하는 경우 하나만이 이를 캐시에 가져오면 되어, 버스를 둘러싼 경쟁을 피할 수 있어서 캐시를 공유하는게 실질적으로 도움이 된다는 것이다. 또한 프로세서 친화력을 적용하지 않는 경우 L2 캐시를 공유하는 것이 피해가 더 적다. 따라서 병렬 프로그램의 경우 L2 캐시를 공유하는 것이 일반적으로 생각하는 것만큼 나쁘지는 않다.

두개의 Pentium 프로세서가 256K 캐시를 공유하는 시스템을 사용해본 경험에 따르면, 필요한 커널 작업의 정도에 따라서 시스템의 성능이 상당히 크게 달라졌다. 최악의 경우 속도가 1.2배 정도밖에 빨라지지 않았지만, "데이터를 가져오는 것은 공유하는(shared fetch)" 효과를 제대로 이용하는 계산 중심적인 SPMD 스타일의 코드를 사용하였을 때 2.1 배까지 빨라지는 것을 보기도 하였다.

버스 설정(Bus configuration)?

먼저 이야기할 것은 요즘에 나오는 대부분의 시스템들은, 프로세서에 하나 이상의 PCI 버스가 연결되어 있고, 이는 또다시 브릿지(bridge)를 통하여 하나 이상의 ISA/EISA 버스에 연결되어 있다는 것이다. 브릿지를 통하는 경우 대기시간(latency)이 늘어나게 되고, EISA나 ISA는 일반적으로 PCI에 비해서 낮은 대역폭을 제공하기 때문에 (ISA가 제일 낮다), 디스크 드라이브나, 비디오 카드, 다른 고성능 장치들은 PCI 버스 인터페이스를 통하여 연결되어야 한다.

PCI 버스가 하나밖에 없더라도 계산 중심적인 병렬 프로그램의 경우 MPS 시스템은 괜찮은 성능개선 효과를 보여준다. 하지만 I/O 처리속도는 하나의 프로세서를 사용할 때보다 더 나아지지 않으며, 프로세서들이 버스를 사이에 두고 경쟁을 하기 때문에 아마도 성능이 조금 떨어지게 될 것이다. 따라서 I/O 속도를 높이고 싶다면 여러개의 독자적인 PCI 버스와 I/O 콘트롤러(예를 들어 여러개의 SCSI 체인들)를 가지는 MPS 시스템을 구입하는 것이 좋다. 이 때 SMP 리눅스에서 이들 시스템을 지원하는지 조심스럽게 살펴보아야 한다. 또한 현재의 SMP 리눅스에서는 어느 순간이든 하나의 프로세서만이 커널모드에 있을 수 있기 때문에, I/O 처리를 할 때 커널에서 소요하는 시간이 적은 I/O 콘트롤러를 사용해야 한다는 점을 명심해야 한다. 정말 고성능을 원한다면 시스템 콜(system call)을 통하지 않고, 사용자 프로세스가 직접 장치로 I/O를 하는 것도 고려해 볼 필요가 있다. 이는 생각만큼 어렵지도 않고, 안정성을 해치지도 않는다 (3.3 장에서 기본 기술에 대해서 설명하고 있다).

버스 속도와 프로세서 클럭 속도의 관계를 살펴보는 것도 매우 중요하다. 지난 몇해동안 이들 사이의 관계는 불명확하게 이해되어 왔다. 대부분의 시스템이 지금은 똑같은 PCI 클럭 속도를 사용하고 있지만, 더 빠른 클럭 속도의 프로세서가 더 느린 버스 클럭과 쌍을 이루는 것은 드문 일이 아니다. 이의 고전적인 예로, 일반적으로 Pentium 133은 Pentium 150보다 더 빠른 버스를 사용하였고, 다양한 벤치마크에서 특이한 결과를 나타냈다. 이런 효과는 SMP 시스템에서 더욱 증폭된다. 이는 버스 클럭 속도를 빠르게 하는 것보다도 더 중요한 문제이다.

메모리 중첩과 DRAM 기술(Memory interleaving and DRAM technologies)?

메모리 중첩은 실제로 MPS와는 아무런 일도 같이 하지 않는다. 그러나 MPS 시스템에서 이것이 종종 언급되는 것을 볼 수 있는데, 이는 이들 시스템이 대체로 메모리 대역폭을 더 많이 필요로 하기 때문이다. 기본적으로 2-way나 4-way 중첩은 RAM에 블럭 접근을 할 때, 이것이 하나가 아니라 여러개의 RAM 뱅크(bank)를 사용하여 이루어지도록 RAM을 조직화한다. 이는 더 높은 메모리 접근 대역폭을 제공하게 되는데, 특히 캐시 라인(cache line) 읽기나 쓰기에 있어서 더욱 그러하다.

이것의 효과에 대해서는 그다지 명쾌하지 않은데, EDO DRAM이나 여러가지 다른 메모리 기술들은 이와 비슷한 종류의 연산 속도를 향상시키기 때문이다. http://www.pcguide.com/ref/ram/tech.htm에서 DRAM 기술에 대해 무척 잘 정리되어있는 개요를 볼 수 있다.

그렇다면, 예를 들어 2-way의 중첩되는 EDO DRAM을 쓰는 것이 중첩을 사용하지 않는 SDRAM을 쓰는 것보다 더 좋은가? 이것은 매우 훌륭한 질문이며, 그 대답은 간단하지 않다. 왜냐하면 중첩기술이나 다른 흥미있는 기술들은 대체로 비싸기 때문이다. 여기에 들어가는 똑같은 돈을 보통 메모리에 투자한다면 훨씬 많은 양의 메인 메모리를 사용할 수 있을 것이다. 가장 느린 DRAM을 사용한다 하더라도 디스크를 이용한 가상 메모리보다는 훨씬 빠르다.

2.2 공유 메모리 프로그래밍에 대한 소개

SMP에서 병렬처리를 사용하는 것이 충분히 할만한 것이라고 결정을 내렸다면, 이제 어디서부터 시작하는게 좋을까? 그럼, 공유 메모리 통신이 실제로 동작하는 방식에 대해서 조금 더 배우는 것으로 그 첫발을 내딛어보도록 하자.

얼핏 생각하면 공유 메모리 통신이란 하나의 프로세서가 메모리에 값을 저장하면, 다른 프로세서가 이를 읽어들이는 것이라고 생각할 수도 있다. 하지만 불행히도 그렇게 간단하지만은 않다. 예를 들어, 프로세스와 프로세서 사이의 관계가 무척 복잡하게 얽혀 되어있다고 하자. 프로세서의 갯수보다 현재 동작하는 프로세스의 수가 적다고 하더라도 그렇고, 그 반대의 경우도 마찬가지다. 이 장의 남은 부분에서는 특별히 신경쓰지 않으면 심각한 문제를 야기할 수 있는 중요한 논점들 - 무엇을 공유할 것인지 판단하는데 사용하는 두가지 서로 다른 모델과, 원자성(atomicity) 논점, 휘발성(volatility) 개념과 하드웨어 락(lock) 명령, 캐시 라인(cache line) 효과, 그리고 리눅스 스케줄러 논점 - 을 간단히 요약하도록 하겠다.

모두 공유하기 대 일부를 공유하기(Shared Everything Vs. Shared Something)

공유 메모리 프로그래밍에서는 일반적으로 모두 공유하기일부를 공유하기라는 두가지의 근본적으로 서로 다른 모델을 사용한다 . 이 두가지 모델은 모두 프로세서들이 공유메모리로 데이터를 쓰고, 공유메모리에서 데이터를 읽어들임으로써 통신을 할 수 있게 한다. 두 모델의 다른점은, 모두 공유하는 모델에서는 모든 자료구조를 공유메모리에 두는 반면에, 일부를 공유하는 모델에서는 사용자가 공유할 자료구조와 하나의 프로세서에 국한되는 자료구조를 명시적으로 지정한다는 것이다.

어떤 공유메모리 모델을 사용할 것인가? 이는 종교에 대한 질문과 비슷하다. 많은 사람들은 자료구조를 선언할 때 이것을 공유할 것인지 따로 구별할 필요가 없기 때문에, 모두 공유하는 모델을 좋아한다. 이 때는 동시에 하나의 프로세스(프로세서)만이 자료에 접근할 수 있도록, 충돌을 일으킬 수 있는 공유하는 자료에 접근하는 코드 주위에 락(lock)을 걸기만 하면 된다. 그렇지만 이것 역시 말처럼 간단하진 않다. 그래서 많은 사람들은 일부만을 공유하는 모델이 가져다주는 상대적인 안전성을 더 선호하기도 한다.

모두 공유하기(Shared Everything)

모두 공유하는 방식의 장점은 이미 만들어져 있는 순차적인 프로그램을 선택하여 쉽게 모두 공유하는 병렬 프로그램으로 변환할 수 있다는 것이다. 여기서는 어떤 자료가 다른 프로세서에서 접근할 수 있는 것인지 먼저 판단해야 할 필요가 없다.

간단하게 살펴보면, 모든걸 공유하는 방식의 가장 큰 문제점은 하나의 프로세서가 취한 행동이 다른 프로세서들에게 영향을 미칠 수 있다는 것이다. 이 문제는 두가지 방향으로 나타난다 :

이런 종류의 문제는 일부를 공유하는 방식에서는 흔히 일어나진 않는다. 왜냐하면 명백하게 지정한 자료구조만이 공유되기 때문이다. 그리고, 모두 공유하는 방식은 모든 프로세서가 완전히 똑같은 메모리 이미지를 실행하는 경우에만 동작한다는 것은 당연한 일이다. 즉, 여러개의 서로 다른 코드 이미지들 사이에서는 모두 공유하는 방식을 사용할 수 없다 (다르게 말하면, SPMD만을 사용할 수 있지, 일반적인 MIMD는 사용할 수 없다).

모두 공유하는 방식을 지원하는 가장 일반적인 유형은 쓰레드 라이브러리(threads library)이다. 쓰레드는 본래, 대체로 일반적인 UNIX 프로세스와는 다르게 스케줄이 이루어지고, 가장 중요한 점으로 동일한 메모리 맵에 접근할 수 있는 "가벼운" 프로세스이다. POSIX Pthreads패키지는 여러 포팅 프로젝트에서 촛점을 받아 왔었다. 여기서 중요한 질문은, 이들 포팅중의 어떤 것들이 실제로 프로그램에 있는 쓰레드들을 SMP 리눅스에서 병렬로 실행할 수 있느냐이다 (이상적으로, 각 쓰레드마다 하나의 프로세서를). POSIX API는 이를 요구하지 않으며, http://www.aa.net/~mtp/PCthreads.html같은 버전에서는 분명하게 병렬 쓰레스 실행을 구현하지 않고 있다 - 프로그램의 모든 쓰레드들은 하나의 리눅스 프로세스 안에 들어있다.

SMP 리눅스에서의 병렬처리를 지원한 첫번째 쓰레드 라이브러리는 지금은 한물간 bb_threads 라이브러리로, ftp://caliban.physics.utoronto.ca/pub/linux/에서 구할 수 있다. 이는 리눅스의 clone()함수를 사용하여, 독자적으로 스케줄되며, 하나의 주소공간을 공유하는, 새로운 리눅스 프로세스를 생성(fork)하는 매우 작은 라이브러이다. SMP 리눅스 기계는 각 "쓰레드들"이 완전한 리눅스 프로세스이기 때문에 여러개의 이들 "쓰레드들"을 병렬로 실행할 수 있다. 대신 이의 댓가로 다른 운영체제의 일부 쓰레드 라이브러리들이 제공하는 것과 같은 "가벼운" 스케줄링 제어를 할 수 없다. 이 라이브러리는 새로운 메모리 조각을 각 쓰레드의 스택으로 할당하고, 락(lock)의 배열(mutex 개체들)들을 원자적으로 접근할 수 있는 함수를, C로 포장된 어셈블리 코드를 조금 사용하여 제공하고 있다. 문서는 README와 간단한 예제 프로그램으로 구성되어 있다.

좀더 최근에 clone()을 사용하는 POSIX 쓰레드 버전이 개발되었다. 이 라이브러리는 LinuxThreads로, SMP 리눅스에서 사람들이 가장 선호하는 모두 공유하는 라이브러리이다. POSIX 쓰레드들도 문서화가 잘되어있고, LinuxThreads READMELinuxThreads FAQ 역시 매우 잘되어 있다. 지금의 주요한 문제는 POSIX 쓰레드를 제대로 하려면 이를 자세하게 알아야한다는 것이고, LinuxThreads는 아직은 계속해서 작업중이라는 것이다. 또한 POSIX 쓰레드 표준이 표준화 과정에서 계속 발전되고 있어, 이미 바뀐 예전 버전의 표준에 맞춰 프로그램을 작성하지 않도록 주의를 기울여야 한다는 것 역시 문제이다.

일부를 공유하기(Shared Something)

일부를 공유하는 것은 정말로 "공유할 필요가 있는 것만을 공유하는" 것이다. 이 접근법은 각 프로세서의 메모리 맵의 똑같은 위치에 공유데이터가 할당되게 하는 것에 유의한다면 일반적인 MIMD(SPMD가 아니라) 용으로 동작하게 된다. 더 중요한 특징은, 일부를 공유하는 방식은 성능을 예측하고 조율하며, 코드를 디버깅하는 것 등을 쉽게 만들어준다는 것이다. 유일한 문제로는 :

현재 리눅스 프로세스 그룹들이 독자적인 메모리 공간을 가지면서, 상대적으로 작은 메모리 영역만을 함께 공유하게 하는데에는 두개의 유사한 방식을 사용한다. 리눅스 시스템을 설정할 때 바보같이 "System V IPC"를 빼버리지 않았다면, 리눅스는 "System V 공유 메모리"라는 다른 시스템 사이에서도 호환성이 있는 방식을 제공한다. 다른 방식은 mmap() 시스템 콜을 통하여 메모리 매핑(memory mapping) 기능을 사용하는 것이다. 이는 구현방식이 UNIX 시스템마다 크게 차이가 난다. 이들 호출에 대해서는 매뉴얼 페이지들에서 배울 수 있다. 그리고 2.5장과 2.6장에 나오는 개괄은 이를 처음 시작할 때 도움이 될 것이다.

원자성과 순서(Atomicity And Ordering)

위의 두가지 모델 중 어떤 것을 사용하더라도 결과는 매우 비슷하다. 여러분은 자신이 만든 병렬 프로그램에 들어 있는 모든 프로세스들이 접근하여 읽고 쓸수 있는 메모리 조각에 대한 포인터를 얻게 된다. 이 말은 내가 만든 병렬 프로그램이 공유 메모리 객체들을 마치 보통의 지역 메모리에 있는 것처럼 접근할 수 있다는 것을 의미하지는 않는다.

원자성이란 한 객체에 대한 작업이 쪼개지지 않고, 중단될 수 없는 일련의 과정으로 이루어지는 것을 가리키는 개념이다. 불행히도, 공유 메모리에 대한 접근은 공유 메모리에 있는 자료에 대한 모든 작업이 원자적으로 이루어진다는 것을 내포하진 않는다. 미리 특별한 주의를 기울지지 않는다면, 버스(bus)에서 단 한번만에 처리가 이루어지는 간단한 읽기/쓰기 연산만이 (즉, 정렬이 된 8, 16, 32 비트 연산이지, 정렬이 안되어 있거나 64 비트 연산은 아니다) 원자성을 가진다. 더욱 나쁜 것은, GCC같이 "똑똑한" 컴파일러는 최적화를 통해 메모리 작업을 제거하여, 한 프로세서가 한 일을 다른 프로세서에서 볼 수 없게 만들어버리기도 한다. 다행히도, 이들 문제들은 모두 고칠 수 있다... 접근 효율성(access efficiency)과 캐시라인 크기(cache line size) 사이의 관계만 걱정거리로 남겨두고서 말이다.

그렇지만 이들 논점에 대해서 토론하기 전에, 이들은 모두 각 프로세서에서의 메모리 참조가 코딩한 순서대로 이루어지고 있다고 가정하고 있다는 것을 지적할 필요가 있다. Pentium은 그렇게 하고 있지만, 앞으로 나올 인텔의 프로세서들은 그렇지 않을 수도 있다는 것도 기억하기 바란다. 따라서, 앞으로 나올 프로세서에 대비하여, 공유 메모리에 접근하는 코드 주위를, 모든 미결된 메모리 접근을 완료하여 메모리 접근이 차례대로 이루어지도록 하는 명령어로 둘러싸야 할 필요가 있다는 것을 깊이 새기길 바란다. CPUID 명령어는 이런 부수효과(side-effect)를 위해 예약되어 있는 것이다.

휘발성(Volatility)

GCC 옵티마이저(optimizer)가 공유 메모리 객체의 값을 레지스터에 버퍼링하는 것을 막으려면, 공유 메모리에 있는 모든 객체들을 volatile 속성을 가지도록 선언해야 한다. 이렇게 하면, 한번의 접근만으로 이루어지는 모든 공유 객체의 읽기/쓰기는 원자적으로 일어나게 된다. 예를 들어, p가 정수에 대한 포인터이고, 이것이 가리키고 있는 정수가 공유 메모리에 있다고 하자. ANSI C에서는 이를 다음과 같이 정의할 수 있다.


volatile int * volatile p;

이 코드에서, 첫번째 volatilep가 가리키는 int 값을 말하며, 두번째 volatile는 포인터 그 자체를 말한다. 물론 이는 귀찮은 작업이지만, GCC가 매우 강력한 최적화를 수행할 수 있도록 하기 위해서 치러야 하는 것이다. 적어도 이론적으로는, GCC에 -traditional 옵션을 주는 것으로도, 몇가지 최적화를 희생하는 대신 올바른 코드를 만들어내는데에는 충분하다. 왜냐하면 ANSI K&R C 이전에는 모든 변수는 따로 register라고 지정하지 않은 이상 모두 volatile이었기 때문이다. 여전히 GCC로 cc -O6와 같이 컴파일을 하고, 필요한 것에만 volatile이라고 지정할 수도 있다.

모든 프로세서의 레지스터를 수정하는 것으로 표시되어 있는 어셈블리어 락(lock)을 사용하면, GCC가 모든 변수들을 다 내보내서(flush), volatile이라고 선언함으로써 발생하는 "비효율적인" 코드들을 피할 수 있게 하는 효과가 있다는 소문이 있어왔다. 이런 방법은 GCC 2.7.0을 사용하는 경우, 정적으로 할당되는 전역변수에 대해서는 제대로 동작하는 것처럼 보인다... 그렇지만, 이런 행동은 ANSI C 표준에서는 필요하지 않다. 더 나쁜 것은 읽기 접근만을 하는 다른 프로세스들은 변수 값을 영원히 레지스터에 버퍼링을 할 수 있어서, 공유 메모리의 값이 실제로 변하는 것을 절대로 알아차리지 못할 수도 있다. 요약하면, 하고 싶은대로 해도 좋지만, volatile라고 지정한 변수만이 제대로 동작한다는 것을 보장할 수 있다.

일반 변수에도 volatile 속성을 암시하는 형변환(type cast)을 사용하여 volatile 접근을 할 수 있다. 예를 들어, 보통의 int i;*((volatile int *) &i);같이 선언하여 volatile로 접근할 수 있다. 이렇게 하면 휘발성(volatility)이 필요한 경우에만, 이런 오버헤드를 사용하도록 할 수 있다.

락(Locks)

++i;는 항상 공유 메모리에 있는 변수 i에 1을 더한다고 생각해왔다면, 다음 이야기는 조금은 놀랍고 당황스러울지도 모르겠다. 하나의 명령으로 코딩을 했다고 하더라도, 값을 읽고 결과를 쓰는 것은 별도의 메모리 처리(transaction)을 통해서 이루어지며, 이 두 처리 사이에 다른 프로세서가 i에 접근할 수도 있다. 예를 들어, 두개의 프로세서가 모두 ++i; 명령을 수행하였는데, 2가 증가하는게 아니라 1이 증가할 수도 있다는 것이다. 인텔 Pentium의 "구조(Architecture)와 프로그래밍 매뉴얼"에 따르면, LOCK 접두어는 다음에 나오는 명령어가 그것이 접근하는 메모리 위치에 대해 원자적으로 이루어지는 것을 보장하기 위해 사용된다.


BTS, BTR, BTC                     mem, reg/imm
XCHG                              reg, mem
XCHG                              mem, reg
ADD, OR, ADC, SBB, AND, SUB, XOR  mem, reg/imm
NOT, NEG, INC, DEC                mem
CMPXCHG, XADD

그렇지만, 이들 연산을 모두 사용하는 것은 그다지 좋은 생각은 아닌 것 같다. 예를 들어, XADD는 386에서는 존재하지도 않고, 따라서 이를 사용하는 것은 호환성의 문제를 발생시킬 수 있다.

XCHG 명령어는 LOCK 접두어가 없더라도 항상 락을 사용한다. 따라서 이 명령어는 세마포어(semaphore)나 공유 큐(shared queue)같은 고수준의 원자적인 구성체를 만드는 경우에 좋은 원자적인 연산이다. 당연히 C 코드로 GCC가 이 명령어를 만들어내도록 할 수는 없다. 대신 인라인(in-line) 어셈블리 코드를 조금 사용해야 한다. 워드(word) 크기의 volatile 객체인 obj와 워드 크기의 레지스터 값인 reg가 있다면, GCC 인라인 어셈블리 코드는 다음과 같다 :


__asm__ __volatile__ ("xchgl %1,%0"
                      :"=r" (reg), "=m" (obj)
                      :"r" (reg), "m" (obj));

락(lock)을 하는데 비트(bit) 연산을 사용하는 GCC 인라인 어셈블리 코드의 예제가 bb_threads library라이브러리의 소스 코드에 있다.

메모리 처리(transaction)를 원자적으로 만드는 것에도 이에 따르는 비용이 있다는 것을 기억할 필요가 있다. 보통의 참조는 지역 캐시를 사용할 수 있다는 사실에 비추어보면, 락(lock)을 하는 연산은 약간의 오버헤드를 수반하고, 다른 프로세서의 메모리 활동을 지연시킬 수 있다. 락(lock) 연산을 사용하는 경우 가장 좋은 성능을 내고 싶다면 가능한 이를 적게 사용하는 것이 좋다. 더 나아가 이들 IA32 원자적인 명령어들은 다른 시스템과의 호환성이 없다.

어떤 순간이든 많아도 하나의 프로세서만이 주어진 공유 객체를 갱신하는 것을 보장하는 여러가지 동기화(synchronization) - 상호 배제(mutual exclusion)를 포함하여 - 를 구현하는데 보통의 명령어를 사용할 수 있도록 하는 여러가지 다른 접근방법이 있다. 대부분의 OS 교재에서는 이들 기법을 적어도 하나 이상씩은 다루고 있다. Abraham Silberschatz와 Peter B Galvin이 지은 운영체제 개념(Operating System Concepts) 4판(ISBN 0-201-50480-4)에서 이를 아주 잘 다루고 있다.

캐시라인 크기(Cache Line Size)

원자성에 관련된 기본적인 것으로서 SMP 성능에 큰 영향을 미칠 수 있는 것으로 캐시라인 크기가 있다. MPS 표준에는 어떤 캐시가 사용되든지 간에 참조는 일관적이어야 한다고 하고 있지만, 사실은 하나의 프로세서가 메모리의 특정 라인에 기록을 할 때, 이전 라인의 캐시된 복사본이 모두 무효화(invalidate)되거나 갱신(update)되어야 한다. 이 말은 두 개나 그 이상의 프로세서가 동시에 같은 라인의 다른 부분에 데이터를 기록하려고 하면, 상당량의 캐시와 버스 통행(traffic)이 발생할 수 있으며, 실질적으로 캐시에서 캐시로 라인을 전달하게 된다. 이 문제는 잘못된 공유(false sharing)라고 한다. 그 해결책은 병렬로 접근되는 데이터가 되도록이면 각 프로세서마다 다른 캐시 라인에서 올 수 있도록 데이터를 조직화하도록 하는 것이다.

잘못된 공유는 L2 캐시를 사용하는 시스템에서는 문제가 안될거라고 생각할 수도 있겠지만, 여전히 별도의 L1 캐시가 있다는 것을 기억하자. 캐시의 조직과 구별된 별도의 레벨의 갯수는 모두 변할 수 있지만, Pentium L1 캐시라인 크기는 32 바이트이고, 전형적인 외장형 캐시라인 크기는 256 바이트 가량이다. 두 항목의 주소가 (물리적 주소이든, 가상 주소이든) ab이고, 가장 큰 프로세서당 캐시라인 크기가 c이고, 이들은 모두 2의 몇 제곱승이라고 하자. 매우 엄밀하게 하면, ((int) a) & ~(c - 1)((int) b) & ~(c - 1)이 같을 때, 두개의 참조가 똑같은 캐시라인에 존재하게 된다. 더 규칙을 간단화하면 병렬로 참조되는 공유 객체가 적어도 c 바이트가 떨어져 있다면, 이들은 다른 캐시 라인으로 매핑이 된다는 것이다.

리눅스 스케줄러 논점(Linux Scheduler Issues)

병렬처리에서 공유 메모리를 사용하는 전적인 이유는 OS의 오버헤드를 피하자는 것이지만, OS 오버헤드는 통신 그 자체 외의 것에서 발생하기도 한다. 우리는 이미 만들어야 할 프로세스의 갯수가 기계에 있는 프로세서의 갯수보다 같거나 작아야한다고 말했었다. 그러나 정확히 얼마나 많은 프로세스를 만들어야 할 지 어떻게 결정할 수 있을까?

최고의 성능을 내려면, 여러분이 작성한 병렬 프로그램에 있는 프로세스의 갯수는, 다른 프로세서에서 계속해서 실행될 수 있는 프로세스의 갯수하고 같아야 한다. 예를 들어, 네개의 프로세서가 있는 SMP 시스템에서 하나의 프로세스가 다른 목적으로 (예를 들어 WWW 서버) 동작하고 있다면, 여러분이 만든 병렬 프로그램은 세개의 프로세스만을 사용해야 한다. 시스템에 몇 개의 다른 프로세스들이 있는지는 uptime 명령에서 돌려주는 "평균 부하(load average)"를 참조하여 대강은 알 수 있다.

다른 방법으로 renice 명령이나 nice() 시스템 콜 같은 것을 이용하여 병렬 프로그램에 있는 프로세스의 우선순위(priority)를 높일 수도 있다. 우선순위를 높이려면 권한이 있어야 한다. 이 생각은 다른 프로세스를 프로세서에서 쫒아내서 자신이 만든 프로그램이 모든 프로세서에서 계속해서 실행될 수 있게 하는 것이다. 이 방법은 http://luz.cs.nmt.edu/~rtlinux/ 에 있는 실시간(real-time) 스케줄러를 제공하는 SMP 리눅스의 프로토타입(prototype) 버전을 사용하면 좀더 확실하게 달성할 수 있다

여러분이 SMP 시스템을 병렬 기계로 사용하는 유일한 사용자가 아니라면, 계속 실행하려고 하는 두 개 이상의 병렬 프로그램 사이에 충돌이 빚어질 수도 있다. 이의 표준 해결방법은 조단위(gang) 스케줄링 - 즉 동시에 하나의 병렬 프로그램에 속하는 프로세스들만이 실행될 수 있도록 스케줄링 우선순위를 다루는 것이다. 그렇지만 하나 이상을 병렬처리하면 결과가 늦게 돌아오고, 스케줄러의 활동이 오버헤드를 더하게 된다는 것을 상기하기 바란다. 따라서, 예를 들어 네 개의 프로세서를 가진 시스템에 두 개의 프로그램을 실행한다면, 두 개의 프로세스를 각각 실행하는 것이, 두 개의 프로그램이 네개의 프로세스를 각각 사용하면서 조단위 스케줄링을 하는 것보다 더 낫다.

이런 문제를 더 꼬이게 하는 것이 하나 더 있다. 여러분이 낮에는 종일 과중하게 사용되고 있지만, 밤에는 완전히 병렬처리용으로만 사용가능한 기계에서 프로그램을 개발하고 있다고 하자. 여러분은 낮에 테스트를 하는것이 느리다는 것을 알더라도 프로그램을 작성하고 작성한 코드를 테스트하고 수정하기 위해 모든 갯수의 프로세스를 만들어 사용할 것이다. 그런데, 프로세스들이 현재 실행되고 있지 않는 (다른 프로세서에서) 다른 프로세스와 공유 메모리로 값을 전달하기만을 아무것도 하지 않고 기다리고 있다면, 이들 작업은 매우 느려질 것이다. 이와 똑같은 문제는 코드를 하나의 프로세서밖에 없는 시스템에서 개발하고 테스트하는 경우에도 발생한다.

해결책은 코드에서 다른 프로세서에서 일어나는 동작을 하염없이 기다려야 하는 부분에, 리눅스가 다른 프로세스를 실행할 수 있는 기회를 주도록 함수 호출을 집어넣는 것이다. 나는 C 매크로를 사용하는데 이를 하는 매크로를 IDLE_ME라고 부르고 있다. 프로그램을 테스트하기 위해서 컴파일을 할때는 cc -DIDLE_ME=usleep(1) 같이 하고, "제품"으로 실행할 때에는 cc -DIDLE_ME={}같이 컴파일을 한다. usleep(1)은 1/1000 초동안 프로세스가 잠들게 하여, 리눅스 스케줄러가 그 프로세서에서 다른 프로세스를 실행하도록 선택할 수 있게 한다. 프로세스의 갯수가 사용가능한 프로세서의 갯수보다 두배이상 많다면, usleep(1)를 사용하는 코드가 이를 사용하지 않는 코드보다 열배이상 빠르게 실행되는 것은 그리 이상한 일이 아니다.

2.3 bb_threads

bb_threads("Bare Bones(뼈만남은)" threads) 라이브러리( ftp://caliban.physics.utoronto.ca/pub/linux/)는 리눅스 clone() 호출의 사용법을 보여주는 아주 간단한 라이브러리이다. tar 파일을 gzip으로 압축하면 겨우 7K 바이트밖에 되지 않는다! 이 라이브러리는 2.4장에서 설명하는 LinuxThreads 라이브러리 때문에 이제 한물간 것이 되었지만, 여전히 쓸만하고, 작고 간단하여 리눅스에서 지원하는 쓰레드의 사용법을 소개하는데에도 알맞다. 분명히 LinuxThreads용 소스코드를 보는 것보다 이 코드를 보는 것이 훨씬 덜 기죽을 것이다. 요약하면 bb_threads 라이브러리는 시작하기 좋은 지점이지만, 큰 프로젝트를 만들때에는 적당하진 않다.

bb_threads 라이브러리를 사용하는 프로그램의 기본적인 구조는 다음과 같다 :

  1. 하나의 프로세스로 프로그램을 시작한다.
  2. 이제 각각의 쓰레드가 필요로 하는 최대 스택의 크기를 계산해야 한다. 이를 크게 잡더라도 그다지 해가 되지 않는다 (이것은 가상 메모리가 존재하는 이유중의 하나이다). 그러나 모든 스택은 하나의 가상 주소 공간에서 나오기 때문에, 너무 크게 잡는 것도 좋은생각이 아니다. 예제에서는 64K를 사용하고 있다. 이 크기를 b 바이트로 설정하려면 bb_threads_stacksize(b)를 호출한다.
  3. 다음 단계는 필요한 락(lock)들을 모두 초기화하는 것이다. 이 라이브러리에서 구현된 락 메커니즘은 락에 0부터 MAX_MUTEXES까지 숫자를 붙이는 것이다. 락 i를 초기화하려면 bb_threads_mutexcreate(i)를 호출한다.
  4. 라이브러리 루틴을 호출하여 새로운 쓰레드를 만든다. 여기에 인자로 새로운 쓰레드가 실행할 함수와, 여기에 전달할 인자들을 넘겨준다. 인자로 arg 하나만 받고, 아무것도 돌려주지 않는 함수 f를 실행하는 쓰레드를 새로 만든다면, 함수 f>를 void f(void *arg, size_t dummy) 처럼 선언을 하고 bb_threads_newthread(f, &arg)함수를 부르면 된다. 하나 이상의 인자를 전달해야하는 경우 인자 값들을 가지고 있는 구조체의 포인터를 넘겨주면 된다.
  5. 병렬 코드를 실행한다. 락을 사용하는 bb_threads_lock(n)bb_threads_unlock(n) 함수를 사용할 때 주의를 기울인다 (여기서 n은 사용할 락을 지정한다). 이 라이브러리에 있는 락을 걸고 락을 해제하는 연산은 원자적인 버스-락(bus-lock) 명령어를 사용하는 매우 기본적인 스핀락(spin lock)이다. 그래서 과도한 메모리 접근 충돌을 일으킬 수 있으며, 어떤 접근도 공정하다는 것을 보증할 수 없다. bb_threads에 함께 딸려오는 예제 프로그램에서 보면 함수 fnnmain에서 동시에 printf()를 실행하는 것을 막는데에 락을 옳바르게 사용하지 않고 있다. 이것 때문에 예제는 항상 동작하지는 않는다. 내가 이 말을하는 것은 예제 프로그램에 트집을 잡기 위해서가 아니라, 이것이 매우 다루기 어렵다는 것과 LinuxThreads를 사용하는 것이 조금 쉽다는 걸 강조하기 위해서이다.
  6. 쓰레드가 return 명령을 실행하면, 이는 실제적으로 프로세스를 죽이게 된다. 그러나 지역 스택 메모리는 자동으로 할당이 해제되지 않는다. 엄밀하게 말하면 리눅스는 할당 해제를 지원하지 않으며, 메모리 공간은 자동으로 malloc()의 사용하지 않는 메모리 목록(free list)으로 되돌아가 추가되지 않는다. 따라서 부모 프로세스는 죽은 자식 프로세스마다 bb_threads_cleanup(wait(NULL))를 불러서 이 공간을 반납해야 한다.

다음 C 프로그램은 1.3장에서 설명한 알고리즘을 사용하여, 두개의 bb_threads 쓰레드를 이용해서 파이(pi)의 근사치를 계산한다.


#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include "bb_threads.h"

volatile double pi = 0.0;
volatile int intervals;
volatile int pids[2];      /* Unix PIDs of threads */

void
do_pi(void *data, size_t len)
{
  register double width, localsum;
  register int i;
  register int iproc = (getpid() != pids[0]);

  /* set width */
  width = 1.0 / intervals;

  /* do the local computations */
  localsum = 0;
  for (i=iproc; i<intervals; i+=2) {
    register double x = (i + 0.5) * width;
    localsum += 4.0 / (1.0 + x * x);
  }
  localsum *= width;

  /* get permission, update pi, and unlock */
  bb_threads_lock(0);
  pi += localsum;
  bb_threads_unlock(0);
}

int
main(int argc, char **argv)
{
  /* get the number of intervals */
  intervals = atoi(argv[1]);

  /* set stack size and create lock... */
  bb_threads_stacksize(65536);
  bb_threads_mutexcreate(0);

  /* make two threads... */
  pids[0] = bb_threads_newthread(do_pi, NULL);
  pids[1] = bb_threads_newthread(do_pi, NULL);

  /* cleanup after two threads (really a barrier sync) */
  bb_threads_cleanup(wait(NULL));
  bb_threads_cleanup(wait(NULL));

  /* print the result */
  printf("Estimation of pi is %f\n", pi);

  /* check-out */
  exit(0);
}

2.4 LinuxThreads

LinuxThreads http://pauillac.inria.fr/~xleroy/linuxthreads/ 는 POSIX 1003.1c 쓰레드 표준에 따라 "모두 공유하는" 방식을 완전하고 튼튼하게 구현한 것이다. 다른 POSIX 쓰레드를 포팅한 것과는 달리, LinuxThreads는 bb_threads에서 사용한 것과 똑같은 리눅스 커널의 쓰레드(clone())를 사용한다. POSIX와 호환된다는 것은 다른 시스템에서 만든 상당수의 쓰레드 프로그램들을 상대적으로 쉽게 포팅할 수 있으며, 참고할 수 있는 다양한 예제가 있다는 것을 의미한다. 간단히 말해, LinuxThreads는 리눅스에서 방대한 규모의 쓰레드 프로그램을 개발할 때 사용할 수 있는 확실한 쓰레드 패키지이다.

LinuxThreads 라이브러리를 사용하는 기본 프로그램 구조는 다음과 같다 :

  1. 하나의 프로세스로 프로그램을 시작한다.
  2. 다음 단계는 필요한 락(lock)들을 모두 초기화하는 것이다. 숫자로 구별되는 bb_threads 락과는 달리 POSIX 락들은 pthread_mutex_t타입(type)의 변수로 선언된다. pthread_mutex_init(&lock,val)함수를 이용하여 필요한 각각의 락들을 초기화한다.
  3. bb_threads에서처럼 새로운 쓰레드를 만들려면 라이브러리 루틴을 호출해야 한다. 여기서 인자는 새로운 쓰레드가 실행할 함수와, 여기에 전달할 인자들이다. 그렇지만 POSIX에서는 사용자가 각 쓰레드를 구별할 수 있도록 pthread_t 타입의 변수를 정의하는 것이 필요하다. f() 함수를 실행하는 pthread_t 쓰레드를 생성하려면 pthread_create(&thread,NULL,f,&arg)를 호출한다.
  4. 병렬 코드를 실행한다. 필요한 경우에 pthread_mutex_lock(&lock)pthread_mutex_unlock(&lock)를 호출하도록 주의한다.
  5. pthread_join(thread,&retval) 함수를 이용하여 각 쓰레드가 끝난후에 마무리를 한다.
  6. C 코드를 컴파일할 때 -D_REENTRANT 옵션을 추가한다.

다음은 LinuxThreads를사용하여 파이(pi)를 계산하는 병렬프로그램의 예이다. 1.3장에서 사용한 알고리즘을 사용하였고, bb_threads 예제에서처럼 두개의 쓰레드가 병렬로 실행된다.


#include <stdio.h>
#include <stdlib.h>
#include "pthread.h"

volatile double pi = 0.0;  /* Approximation to pi (shared) */
pthread_mutex_t pi_lock;   /* Lock for above */
volatile double intervals; /* How many intervals? */

void *
process(void *arg)
{
  register double width, localsum;
  register int i;
  register int iproc = (*((char *) arg) - '0');

  /* Set width */
  width = 1.0 / intervals;

  /* Do the local computations */
  localsum = 0;
  for (i=iproc; i<intervals; i+=2) {
    register double x = (i + 0.5) * width;
    localsum += 4.0 / (1.0 + x * x);
  }
  localsum *= width;

  /* Lock pi for update, update it, and unlock */
  pthread_mutex_lock(&pi_lock);
  pi += localsum;
  pthread_mutex_unlock(&pi_lock);

  return(NULL);
}

int
main(int argc, char **argv)
{
  pthread_t thread0, thread1;
  void * retval;

  /* Get the number of intervals */
  intervals = atoi(argv[1]);

  /* Initialize the lock on pi */
  pthread_mutex_init(&pi_lock, NULL);

  /* Make the two threads */
  if (pthread_create(&thread0, NULL, process, "0") ||
      pthread_create(&thread1, NULL, process, "1")) {
    fprintf(stderr, "%s: cannot make thread\n", argv[0]);
    exit(1);
  }

  /* Join (collapse) the two threads */
  if (pthread_join(thread0, &retval) ||
      pthread_join(thread1, &retval)) {
    fprintf(stderr, "%s: thread join failed\n", argv[0]);
    exit(1);
  }

  /* Print the result */
  printf("Estimation of pi is %f\n", pi);

  /* Check-out */
  exit(0);
}

2.5 System V 공유 메모리

시스템 V IPC(Inter-Process Communication, 프로세스간 통신)는 메시지 큐(message queue)와 세마포어(semaphore), 공유 메모리(shared memory) 메커니즘을 제공하는 여러가지 시스템 콜을 지원한다. 물론 이들 메커니즘은 원래 하나의 프로세서를 사용하는 시스템에서 여러개의 프로세스들이 통신을 하는데 사용하기 위해서 만들어졌다. 그렇지만, 이 말은 SMP 리눅스에서 프로세스가 어떤 프로세서에서 실행되고 있든지 간에 프로세스간 통신에서도 제대로 동작해야 한다는 의미를 내포하고 있다.

이들 시스템 콜이 어떻게 사용되는지 살펴보기 전에, 시스템 V IPC 호출이 세마포어나 메시지 전달같은 일을 위해 존재하긴 하지만, 이를 사용해선 안된다는 것을 이해하는 것이 중요하다. 왜 안되느냐? 이들 함수들은 일반적으로 느리고 SMP 리눅스에서는 직렬화(serialize)되어 있다. 이정도면 충분하겠다.

공유 메모리 영역으로의 접근을 공유하는 프로세스 그룹을 만드는 기본적인 과정은 다음과 같다 :

  1. 하나의 프로세스로 프로그램을 시작한다.
  2. 대체로 여러분은 실행되는 각각의 병렬 프로그램이 자신만의 공유 메모리 영역을 가지기를 바랄 것이다. 따라서 shmget() 함수를 불러 원하는 크기만큼의 새로운 영역을 만들어야 한다. 이 호출은 이미 존재하는 공유 메모리 영역의 ID를 얻는데에도 사용할 수 있다. 어떤 경우이든, 돌아오는 값은 공유 메모리 영역의 ID이거나 에러가 발생한 경우 -1이다. 예를 들어, b 바이트 크기의 공유 메모리 영역을 만든다면, shmid = shmget(IPC_PRIVATE, b, (IPC_CREAT | 0666)) 같이 사용할 수 있다.
  3. 다음 단계는 이 공유 메모리 영역을 이 프로세스에 연결하는(attach) 것이다. 말 그대로 이 메모리를 이 프로세스의 가상 메모리 맵에 추가하는 것이다. 프로그래머는 shamt() 함수 호출에서 메모리 영역이 나타날 가상 주소를 지정할 수 있지만, 선택한 주소는 페이지 경계(boundary)에 따라 정렬(align)이 되어 있어야 하며 (즉, getpagesize()에서 돌려주는 페이지 크기 - 보통은 4096 바이트이다 - 의 배수여야 한다), 이는 이 주소에 이미 존재하던 어떤 메모리이든지간에 매핑을 덮어써버린다. 따라서, 이보다는 시스템이 주소를 고를 수 있도록 하는 것이 더 선호된다. 어떤 경우이든, 돌아오는 값은 매핑이 된 세그먼트가 시작하는 가상주소에 대한 포인터이다. 코드는 shmptr = shmat(shmid, 0, 0)과 같은 형태이다. 모든 공유 변수들을 구조체의 멤버로 선언하고 shmptr을 이 구조체에 대한 포인터로 선언함으로써 간단하게 모든 정적 변수를 공유 메모리 영역으로 할당할 수 있다. 이 기법을 이용하여, 공유 변수 xshmptr->x로 접근할 수 있다.
  4. 공유 메모리 영역을 사용하는 마지막 프로세스가 종료하거나 이 영역에서 떨어져나오면(detach) 이 공유 메모리 영역을 없애야한다. 이 기본 행동을 설정하려면 shmctl() 함수를 부를 필요가 있다. 코드는 shmctl(shmid, IPC_RMID, 0)과 같은 형태로 작성한다.
  5. 원하는 갯수로 프로세스들을 만들려면 표준 리눅스의 fork() 함수를 사용한다. 각각의 프로세스는 공유 메모리 영역을 상속받게된다.
  6. 프로세스가 공유 메모리 영역을 사용하는 작업을 끝마치면, 이 공유 메모리 영역으로부터 분리(detach)해야 한다. 이는 shmdt(shmptr)을 불러서 한다.

위에 설명한 과정에서는 몇개 안되는 시스템 호출만을 사용하지만, 일단 공유 메모리 영역이 만들어지면, 하나의 프로세스가 메모리상의 값을 바꾼 경우 자동으로 모든 프로세스에 보이게 된다. 가장 중요한 점은 각 통신 작업이 시스템 콜을 하는 오버헤드없이 이루어진다는 것이다.

다음은 시스템 V 공유 메모리 영역을 사용하는 C 프로그램의 예이다. 이 프로그램은 파이(pi)를 계산하는 것으로 1.3장에서 나온 것과 똑같은 알고리즘을 사용한다.


#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/ipc.h>
#include <sys/shm.h>

volatile struct shared { double pi; int lock; } *shared;

inline extern int xchg(register int reg,
volatile int * volatile obj)
{
  /* Atomic exchange instruction */
__asm__ __volatile__ ("xchgl %1,%0"
                      :"=r" (reg), "=m" (*obj)
                      :"r" (reg), "m" (*obj));
  return(reg);
}

main(int argc, char **argv)
{
  register double width, localsum;
  register int intervals, i;
  register int shmid;
  register int iproc = 0;;

  /* Allocate System V shared memory */
  shmid = shmget(IPC_PRIVATE,
                 sizeof(struct shared),
                 (IPC_CREAT | 0600));
  shared = ((volatile struct shared *) shmat(shmid, 0, 0));
  shmctl(shmid, IPC_RMID, 0);

  /* Initialize... */
  shared->pi = 0.0;
  shared->lock = 0;

  /* Fork a child */
  if (!fork()) ++iproc;

  /* get the number of intervals */
  intervals = atoi(argv[1]);
  width = 1.0 / intervals;

  /* do the local computations */
  localsum = 0;
  for (i=iproc; i<intervals; i+=2) {
    register double x = (i + 0.5) * width;
    localsum += 4.0 / (1.0 + x * x);
  }
  localsum *= width;

  /* Atomic spin lock, add, unlock... */
  while (xchg((iproc + 1), &(shared->lock))) ;      
  shared->pi += localsum;
  shared->lock = 0;

  /* Terminate child (barrier sync) */
  if (iproc == 0) {
    wait(NULL);
    printf("Estimation of pi is %f\n", shared->pi);
  }

  /* Check out */
  return(0);
}

나는 이 예제에서 락(lock)을 구현하기 위해 IA32의 원자적인(atomic) 교환(exchange) 명령어를 사용하였다. 더 나은 성능과 호환성을 바란다면, 원자적인 버스-락(bus-lock) 명령어를 사용하지 않는 동기화 기법으로 대체하기 바란다.

현재 사용하고 있는 시스템 V IPC 기능들의 상태를 보여주는 ipcs을 기억하고 있다면, 여러분이 만든 코드를 디버깅할 때 도움이 될 것이다.

2.6 메모리 맵 호출

파일 I/O 시스템 콜을 사용하는 비용은 매우 클 수 있다. 사실, 이것이 사용자 버퍼를 사용하는 파일 I/O 라이브러리가 있는 이유이다 (getchar(), fwrite() 등). 그러나 사용자 버퍼는 여러개의 프로세스가 똑같은 쓰기 가능한 파일에 접근하고 있다면 사용할 수 없으며, 사용자 버퍼를 관리하는 오버헤드도 꽤 크다. BSD UNIX에서는 파일의 일부를 사용자 메모리로 매핑하여 본질적으로 가상 메모리 페이징 메커니즘을 통해 갱신을 하도록 할 수 있는 시스템 콜을 추가하여 이를 해결한다. 이와 똑같은 메커니즘이 몇년전 Sequent에서 만든 시스템에서 공유 메모리 병렬처리 지원의 기반으로 사용되었다. (아주 오래된) man 페이지에 몇가지 매우 부정적인 의견이 있음에도 불구하고, 리눅스는 기본적인 함수들 중 적어도 몇가지는 제대로 수행하는듯이 보이며, 이 시스템 콜을 여러개의 프로세스가 공유할 수 있는 무명의(anonymous) 메모리 영역으로의 매핑에 사용하는 것을 지원한다.

본질적으로 리눅스에서의 mmap() 구현은 2.5장에서 설명한 2, 3, 4번째 단계를 하나로 대체한 것이다. 무명의 공유 메모리 영역을 만들려면 :


shmptr =
    mmap(0,                        /* system assigns address */
         b,                        /* size of shared memory segment */
         (PROT_READ | PROT_WRITE), /* access rights, can be rwx */
         (MAP_ANON | MAP_SHARED),  /* anonymous, shared */
         0,                        /* file descriptor (not used) */
         0);                       /* file offset (not used) */

시스템 V 공유메모리의 shmdt() 함수와 똑같은 일을 하는 함수는 munmap()이다 :


munmap(shmptr, b);

내 생각에는 시스템 V 공유 메모리 지원 대신 mmap()을 사용하는 것이 실제로 더 낫지는 않다.


다음 이전 차례