C/C++에서 포인터를 초기화 할 때 ‘NULL’을 사용합니다. 그러나 VC++ 10에는 C++0x에서는 포인터를 초기화 할 때 NULL 대신 새로 생긴 ‘nullptr’을 사용할 수 있게 되었습니다.
C++/CLI는 이전부터
nullptr이 있었습니다.
C++/CLI에서는 ref 클래스의
핸들을 초기화 할 때는 nullptr을 사용합니다.
C++/CLI, C++0x의
nullptr은 C/C++ 처럼 ‘0’이 아니라는
것을 잘 기억하시기 바랍니다.
interior_ptr
interior_ptr은 관리 힙(managed
heap. 즉 GC겠죠) 상의 value type나 기본형을 가리키는 포인터라고 할 수 있습니다.
interior_ptr는 value type나 기본형을 비관리 코드의 포인터처럼 사용하고
싶을 때 사용하면 좋습니다.
< 코드 1. >
ref class REFClass
{
public:
int nValue;
};
void SetValue( int* nValue )
{
*nValue = 100;
}
int main()
{
REFClass^ refClass = gcnew REFClass;
SetValue( &refClass->nValue ); // 에러
}
위 코드를 빌드 해 보면 SetValue(
&refClass->nValue ); 에서 빌드 에러가 발생합니다. 매니지드
힙에 있는 것은 그 위치가 변하므로 비 관리 코드의 포인터를 넘길 수가 없습니다. 그럼 <코드 1>를 정상적으로 빌드 하기 위해서 interior_ptr를 사용해 보겠습니다.
< 코드 2. >
ref class REFClass
{
public:
int nValue;
};
void SetValue( interior_ptr<int>
nValue )
{
*nValue = 100;
}
int main()
{
REFClass^ refClass = gcnew REFClass;
SetValue( &refClass->nValue );
}
<코드 2>의 SetValue의 파라미터로 비관리 코드의 참조나 포인터를 넘길 수도 있습니다.
< 코드 3. >
#include <iostream>
void SetValue( interior_ptr<int>
nValue )
{
*nValue = 100;
}
int main()
{
int
nValue = 50;
SetValue(
&nValue );
std::cout
<< nValue << std::endl;
getchar();
return
0;
}
그리고 interior_ptr에 대신 C++/CLI의 참조(‘%’)를 사용하는 방법도 있습니다.
pin_ptr
pin_ptr은 관리 힙 상의
value type나 기본형을 비관리 코드에서 포인터로 사용하고 싶을 때 사용하는 기능입니다. 가장
필요한 경우가 C++/CLI에서 기존의 비관리 코드로 만들어 놓은 라이브러리를 사용할 때입니다.
< 코드 4. >
ref class REFClass
{
public:
int nValue;
};
void SetValue( int* pValue )
{
*pValue = 100;
}
int main()
{
REFClass^ refClass = gcnew REFClass;
pin_ptr<int> pValue = &refClass->nValue;
SetValue( pValue );
pValue = nullptr;
}
pin_ptr에 메모리 주소를 할당하는 것을 ‘pin’이라고 부르고 사용이 끝난 후 nullptr로 초기화 하는
것을 ‘unpin’ 이라고 부릅니다. pin_ptr 사용이
끝난 후 가능한 빨리 unpin 해주는 것이 좋습니다.
interior_ptr과 pin_ptr의 차이점
interipor_ptr과
pin_ptr은 둘 다 관리 힙 상의 value type이나 기본형을 가리키는 포인터로
사용되지만 interior_ptr은 관리 힙 상에서 인스턴스가 이동하여도 올바르게 추적할 수 있는 포인터로
런타임의 지배하에 있습니다(즉 인스턴스가 관리 힙 상에서 이동하여도 괜찮습니다).
pin_ptr은 관리 힙 상의
value type을 비관리 코드에서 사용하고 싶을 때 사용합니다. 당연히 이 때는 관리
힙에 있는 인스턴스가 이동하면 안되므로 인스턴스의 이동을 금지합니다.
interipor_ptr과
pin_ptr의 같은 점 : 포인터처럼 사용할 수 있다.
interipor_ptr과
pin_ptr 다른 점 : interipor_ptr은 관리 코드 상에서 포인터로 사용하고, pin_ptr는 비관리 코드에 포인터로 넘길 때 사용합니다.
interipor_ptr과
pin_ptr을 공부했으니 다음에는 C++/CLI에서 비관리 C++과 혼합해서 사용할 때 어떻게 해야 하는지 설명하겠습니다.
프로그래밍 할 때 가장 자주 사용하는 자료구조가 바로 배열입니다. 배열을
사용하지 않고 프로그래밍 하기는 힘들죠^^.
그래서 이번에는 C++/CLI에서의 배열에 대해서 이야기하려고 합니다.
C++/CLI에서의 배열은 ‘array’
비관리 C++에서는 배열은 ‘[]’을
사용합니다.
int Nums[10];
char szName[20] = {0,};
그러나 C++/CLI에서의 배열은 ‘array’라는
클래스를 사용합니다.
int 형의 3개의 요소를
가지는 배열은 아래와 같이 정의합니다.
array< int >^ A1 = gcnew array<
int >(3);
array< int >^ A2 = gcnew array<
int >(4) { 1, 2, 3 };
array< int >^ A3 = gcnew array<
int >{ 1, 2, 3 };
다음은 간단한 사용 예입니다.
< 코드 1. >
int main()
{
array<
int >^ Numbers = gcnew array< int >(5);
for(
int i = 0; i < 5; ++i )
{
Numbers[
i ] = i;
System::Console::WriteLine(
Numbers[i] );
}
getchar();
return
0;
}
array에 유저 정의형 사용하기
array에는 기본형(int,
float 등)만이 아닌 유저 정의형도 사용할 수 있습니다. 다만 비관리 클래스는 안됩니다. 오직 관리 클래스(ref class)만 가능합니다. 또 그냥 ref 클래스를 그대로 넣을 수는 없는 클래스의 핸들을 사용해야 합니다(ref
클래스는 GC에 동적 할당을 하기 때문이겠죠).
ref class refTest
{
};
array< refTest >^ arrTest;// 에러
array< refTest^ >^ arrTest;// OK
for each 사용하기
앞서 <코드1>의
예제에서는 배열의 모든 요소를 순환하기 하기 위해 ‘for’문을 사용했습니다. 그러나 .NET에서는 for문
보다 ‘for each’문을 사용하는 것이 성능이나 안정성 등에서 더 좋습니다(다만 for each를 사용하면 내부에서 값을 변경할 수 없다는 문제는
있습니다).
< 코드 2. >
#include <iostream>
int main()
{
array<
int >^ Numbers = gcnew array< int > { 10, 11, 12, 13, 14 };
앞서 두 번을 걸쳐서 C++/CLI에 대해서 잘 모르는 분과 싫어하는
분을 위해서 제 생각이나 MSDN에 있는 글을 정리해서 포스팅 했습니다.
이제 본격적으로 C++/CLI에 대해서 설명해 나가겠습니다(이 글을 보는 분들은 C++을 알고 있다고 가정을 하겠습니다).
1. ‘C++/CLI가 뭐야?’
라고 질문을 하면 가장 초 간단한 답은 ‘.NET에서 C++를
사용하기 위한 언어’ 라고 말할 수 있습니다. 그런데 이 답은 너무 간단하고 없어 보이죠? ^^;
그래서 좀 유식하게 보일 수 있도록 고급스럽게 답해 보겠습니다(또는 복잡하게).
C++/CLI에서 CLI는
‘Common Language Infrastructure’의 약자입니다.
C++/CLI는 CLI 환경에서
돌아가는 프로그램을 만들기 위한 언어입니다. C++/CLI는 마이크로소프트(이하 MS)가 만들었지만 공업 표준화 단체인 ECMA에 의해서 표준 언어로 제정 되어 있습니다.
C++/CLI가 MS가
만들었기 때문에 현재까지는 실행 환경이 Windows의 .NET 플랫폼이지만
언어 사양 상으로는 Windows나 .NET 플랫폼에만 사용할 수 있는 것이 아닙니다. 이론적으로는 Windows 이외의 Unix나 Linux, Mac에서도 실행할 수 있습니다(누구라도 Windows 이외서도 사용할 수 있도록 언어 사양을 따라서
구현만 하면 됩니다).
C++/CLI는 C++로
만든 프로그램을 거의 그대로 컴파일 할 수 있습니다.
C++의 표준 기능에 CLI를
위한 추가 기능이 더해져 있습니다.
2. 가장 많은 프로그래밍 언어로 만드는 프로그램 만들기
가장 많은 프로그래밍언어로 만드는 프로그램은 무엇일까요? 제가 생각하기에는
그 유명한 ‘Hello World’라고 생각합니다. 제가
공부한 대부분의 프로그래밍 언어 책에는 첫 번째 예제 프로그램이 Hello World였습니다.
그래서 C++/CLI도 관례(?)에
맞추어서 ‘Hello World’ 프로그램을 만들어 보겠습니다.
(VS의 마법사 기능에 의해서 코딩을 하나도 하지 않고 프로그램이 만들어집니다.)
< 그림 1. CLR 콘솔 어플리케이션을 선택합니다 >
< 리스트 1. ‘Hello
World’ >
#include
"stdafx.h"
using namespace
System;
int
main(array<System::String ^> ^args)
{
Console::WriteLine(L"Hello World");
return 0;
}
<리스트 1>의
코드는 <그림 1>에서 ‘OK’ 버튼을 누른 후 자동으로 생성되는 코드입니다. 이것을 빌드
후 실행을 하며 아래와 같은 결과가 나옵니다.
그런데 문제는 실행과 동시에 종료되어서 결과를 볼 틈이 없습니다.
예전에 C++에서는 이런 경우 코드의 아래에 ‘getchar()’를 사용하여 결과를 보고 종료시켰습니다.
그런데 다들 아시겠지만 ‘getchar()’라는 함수는 .NET 라이브러리에 있는 함수가 아닌 네이티브의 함수입니다. C#이라면
바로 사용하지 못하겠지만 C++/CLI는 이런 네이티브의 함수를 바로 사용할 수 있습니다.
<리스트 2. getchar()
사용 >
#include
"stdafx.h"
#include
<stdio.h>
using namespace
System;
int
main(array<System::String ^> ^args)
{
Console::WriteLine(L"Hello World");
getchar();
return 0;
}
<리스트 2>는
.NET 라이브러리와 네이티브가 자연스럽게 공존하고 있습니다.
이런 것이 C++/CLI이기 때문에 가능한 것입니다.
C++/CLI로 만들어진 'Hello World' 소스 코드를 보면 C++ 프로그래머라면 몇개 처음보는 것이 있지만 이름만 봐도 대충 어떤 의미를 가지고 있는지 쉽게 파악할 수 있어서 소소 코드가 전혀 어렵지 않을 것입니다.
C++/CLI는 기존의 C++에서 CLI가 더해진 것으로 간단하게 말하면 이 더해진 'CLI'만 공부하면 C++/CLI는 마스터합니다.
앞으로 더해진 'CLI' 부분에 대해서 설명해 나가겠습니다.
다음에는 C++의 트레이드마크인 ‘클래스’와 C에서부터 친숙한 struct가 C++/CLI에서는 어떤 의미를 갖고 어떻게 사용되는지 알아 보겠습니다.
이 글을 통해서 C++/CLI의 특징이나 .NET 언어 중에서 어떤 특징을 가지고 있는지 알 수 있습니다.
유연한 프로그래밍 모델
C++는 C#에 비해 언어적
표현력이나 라이브러리가 부족하다는 단점이 있지만 C#에 비해서 프로그래머가 가질 수 있는 자유도가 무척
높습니다(때로는 이것 때문에 많은 문제를 일으키지만).
C#으로 프로그래밍을 할 때는 무조건 객체 지향 프로그래밍 모델만
사용해야 합니다. 그러나 C++는 절차형 프로그래밍, 객체 지향 프로그래밍, 제너릭 프로그래밍, 메타 프로그래밍 등 프로그래머가 원하는 모델을 선택하여 프로그래밍 할 수 있습니다.
.NET에서 C++/CLI를
사용하면 이런 C++의 장점을 바로 얻을 수 있습니다.
어떤 .NET 언어보다도
뛰어난 성능
보통 .NET에서 프로그래밍 할 때 어떤 언어를 사용하나 동일한 성능을
낸다고 생각하지만(즉 언어는 달라도 컴파일 결과로 나오는 MSIL은
동일하다고) 이것은 잘 못된 생각입니다.
C++ 컴파일러 팀은 수년에 걸쳐 네이티브 코드의 최적화로부터 얻었던
지식을 C++/CLI의 최적화에 적용할 수 있도록 많은 노력을 했습니다. 그 결과 다른 .NET 언어보다 최적화된 MSIL를 만들어냅니다.
VC++은 어느 컴파일러보다 최상의 최적화를 제공하고 이것은 네이티브
뿐만이 아닌 매니지드 코드에 대해서도 같습니다. VC++ 컴파일러는 네이티브 코드의 모든 최적화 방법을 MSIL에도 적용하여 다른 .NET 언어보다 더 뛰어난 최적화를 할
수 있습니다.
.NET에서 가장 최적화된 .NET
코드를 만들어 내는 것은 C++/CLI 입니다.
네이티브 코드와 상호 운용 가능
사실
C++/CLI가 .NET에서 가장 큰 의미를 갖는 것이 바로 이 부분이라고 생각합니다.
.NET의 다른 언어에서 네이티브 코드를 사용하려면 네이티브 코드를 DLL로 만들어서 P/Invoke로 호출해야 합니다. 그러나 C++/CLI는 네이티브 코드와 매니지드 코드를 혼합하여
사용할 수 있습니다. 네이티브 함수로부터 매니지드 함수를 호출하는 경우 특별한 구문을 기술할 필요가
없습니다.
네이티브에서 매니지드 호출 또는 매니지드에서 네이티브를 호출하는 경우 서로간의 경계를 넘어가야 하므로 비용이
발생하는데 이런 호출을 최대한 줄여야 성능에 좋습니다. C#의 경우에는 서로간의 호출을 줄여야 하는
경우 인터페이스 변경 등이 필요하나 C++/CLI는 /clr 스위치를
사용하는 것으로 쉽게 변경할 수 있습니다. 이러한 결과로 네이티브와 매니지드 간의 호출에 발생하는 비용을
최소화 할 수 잇습니다.
매니지드 코드와 네이티브 코드의 상호 운용에서 가장 비싼 비용은 마샬링입니다.
C#의 경우 P/Invoke를 호출할 때 CLR에
의해서 암묵적으로 마샬링이 실행됩니다. 그러나 C++/CLI는
프로그래머가 필요에 따라서 명시적으로 마샬링을 할 수 있어서 한번 마샬링한 데이터를 복수로 사용할 수 있어서 마샬링에 의한 비용을 줄일 수 있습니다.
.NET 프로그램의 처음 실행 시의 딜레이
.NET으로 만든 프로그램과 네이티브로 만든 프로그램의 차이 중의
하나가 .NET으로 만든 프로그램은 처음 실행 시에 CLR을
읽기 위해 딜레이가 발생하는 것입니다. 그러나 C++/CLI는
이런 문제를 회피할 수 있습니다.
VC++에는 DLL 딜레이
로딩 이라는 기능이 있습니다. 링커 옵션에서 /DELAYLOAD:dll에 딜레이 로딩을 할 .NET 어셈블리를 지정하면 네이티브 프로그램과 동일한 정도의 속도록 실행 시킬 수 있습니다.
좀 더 C++/CLI가
다른 .NET 언어보다 좋은 점이 있지만 이것으로 줄이겠습니다.
C++/CLI를 아시는 분들은 언어적 위치의 애매함에 의해서 좋지 않은 인상을 가진 분들이 많으리라 생각하는데 제가 소개한 장점을 통해서
조금이나마 좋은 인상을 얻으셨는지 모르겠네요^^.
C++/CLI가 다른 .NET 언어보다 어떤 특징을 가지고 있는지 좀 더 자세하게 알고 싶다면
위에 소개한 MSDN 매거진의 원문(영어)을 보시던가 또는 제가 발 번역한 글을 보시기 바랍니다.
안녕하세요. 저는 작년에 스터디에 합류하면서 C++0x나 VC++ 10의
Concurrency Runtime에 대해서 글을 적고 강연을 했었습니다. 아직 C++0x나 Concurrency Runtime에 대한 모든 것을
다 다루지 못했지만 이번에 새로운 멤버들이 합류하여 이 분들이 제가 이전에 맡았던 부분을 맡아서 할 예정이므로 저는 새로운 것을 하려고 합니다.
제가 하려는 것은 C++/CLI에 대한 것입니다.
사실 원래 작년부터 저는 C++/CLI을 공부하면서 정리해 보려고
했는데 작년 4월에 스터디에 합류하면서 계속 미루어졌습니다.
보통 .NET에서 프로그래밍을 한다고 하면 C#으로 프로그래밍 한다고 생각합니다. 그리고 네이티브 프로그래밍을
한다고 하면 C++입니다. 그럼 C++/CLI는 무엇일까요?
이름으로 유추 해 볼까요? ‘C++/CLI의 첫 단어는 ‘C++’입니다. ‘C++’ 이라고 하니 딱 네이티브 냄새가 나는군요. 두 번째 단어는 ‘CLI’입니다.
C#으로 프로그래밍 하시는 분들은 ‘CLI’가 무엇을 뜻하는지 아시죠? ‘CLI’는 .NET의 냄새를 풀풀 풍깁니다. ‘C++/CLI’라는 단어는 네이티브와 .NET의 냄새가 동시에
나지 않나요? 만약 그렇다고 생각하신 분들은 냄새를 잘 맡은 것입니다.^^
‘C++/CLI’가 네이티브와
.NET의 냄새를 동시에 풍기듯이 프로그래밍 측면에서도 ‘C++/CLI’는 네이티브와 .NET의 중간 지점에 있는 언어라고 할 수 있습니다.
폭 넓은 또는 이도 저도 아닌 어중간한
어떤 영역에서 중간 정도의 위치에 있으면 좋은 말로는 한쪽에 쏠리지 않으면서 양쪽 모든 것을 다 할 수 있다고
들을 수 있지만, 나쁜 말로는 이도 저도 아니라는 말도 들을 수 있습니다.
C++/CLI도 그런 것 같습니다.
먼저 C++/CLI의 나쁜 점은
.NET 프로그래머와 네이티브 프로그래머 양쪽 모두가 썩 마음에 들어하지 않는 언어라는 것입니다. .NET
프로그래머가 보기에는 이미 C#이라는 좋은 언어가 있어서 다른 언어는 눈에 잘 들어오지도
않고, 네이티브에는 관심이 없는 분들도 많습니다.
네이티브 프로그래머에게는 C++라는 걸출한 언어가 있고 일의 특성상 .NET으로는 하기 힘들어서 .NET 프로그래머가 사랑하는 C#도 들어올 공간이 없을 정도입니다. 그런데 C++/CLI를 보니 ‘C++’이라는 단어에 반가움을 가지고 봤더니
C++와 비슷한 것 같으면서 문법이 미묘하게 다른 것이 꽤 마음에 들지 않습니다(C++ 프로그래머가 보기에는 해괴하게 생겼습니다).
C#, C++ 어느 하나도 제대로 마스터하기 힘든데 C++/CLI를 제대로 사용하기 위해서는 .NET 프로그래머는 C++을 알아야 하고, C++ 프로그래머는 .NET을 알아야 합니다. 이 바쁜 세상에 하나도 제대로 마스터하기
힘든데 두 개나 알아야 합니다. -__-
혹시 여기까지 읽고 “C++/CLI 나쁜 언어군. 이런 잉여 언어”라고 생각하고 창을 닫으려고 하는 건 아니겠죠? 조금만 더 기다려 보세요. 이제 좋은 말을 좀 할 테니 이것도 보세요^^
그럼 이제 좋은 점을 말해 보겠습니다.
.NET과 C++(네이티브) 둘 다 각각의 장점을 많이 가지고 있지만 완벽하지는 않습니다.
.NET 프로그래머 입장에서는 아직 세상에는 C++나 C로 만들어진 라이브러리가 많고, .NET은 C++에 비해서 아직은 성능이 낮기 때문에 .NET만으로는 부족한 경우가 있습니다. 또 C++ 프로그래머 입장에서는 요즘 같이 생산성을 추구하는 환경에서 .NET에
비해 낮은 생산성 때문에.NET 환경이 부럽습니다. C++/CLI는
이런.NET 프로그래머와 C++(네이티브) 프로그래머의 아쉬운 부분을 해결해 줄 수 있습니다.
물론 C++/CLI를 사용하지 않아도 .NET에서 네이티브 라이브러리를 사용하고, C++ 프로그래머는 .NET으로 만들어진 라이브러리를 사용할 수 있지만 규모가 작지 않은 경우라면 C++/CLI를 사용하는 것보다 까다롭습니다.
제가 일하고 있는 게임 업계에서는 주력 언어는 C++이고, 예전이나 지금 만들고 있는 대 부분의 라이브러리는 C++을 사용하여
만들고 있습니다. 그러나 게임 개발을 위한 회사에서 사용할 인하우스 툴을 만들 때는 C++만큼의 고 성능을 필요로 하지 않으면서 C++로 만든 라이브러리를
사용하고, 빠르게 개발하고 유지보수가 간편하기를 원하므로 .NET과
네이티브가 결합되기를 바랍니다. 바로 이럴 때가 ‘C++/CLI’가
적격입니다.
C++/CLI의 가장 큰 단점은
C# 프로그래머가 보기에도 이상하고, C++ 프로그래머가 보기에도 생김새가 이상해서 흥미를
일으키지 못한다는 점과, C++/CLI를 올바르게 알기 위해서는 .NET과 C++을 모두 알아야 된다는 점이라고 생각합니다.
그러나 이 단점이라는 부분이 .NET과 C++ 양쪽 모두를 아는 프로그래머에게는 어느 한쪽만을 아는 프로그래머에 비해 양쪽의 장점을 적절하게 다 가져갈
수 있습니다.
.NET이나 C++ 한쪽만을
아는 사람에게는 C++/CLI는 미운 오리새끼이지만 양쪽 모두 아는 사람에게는 백조가 될 수 있습니다.
이 블로그를 통해서 아직까지는 미운 오리새끼로 취급 받고 있는 C++/CLI를
백조가 될 수 있도록 해보겠습니다.
적다 보니 생각보다 글이 길어졌네요. 첫 글을 너무 쓸데 없이 주절댄
것은 아닌가 모르겠습니다.^^;
다음부터는 .NET에서의 C++/CLI의
의미에 대해서 정리하고, C++/CLI에 대한 설명, C++/CLI를
사용한 .NET과 네이티브의 결합, C++/CLI로 .NET Framework 4 사용 이라는 순서로 글을 올려보겠습니다.
ps : C++/CLI는 .NET이
처음 나올 때는 ‘Managed C++’이라는 이름으로 불렸습니다. 그러다가 VS 2005가 나오면서 C++/CLI로 이름이 바뀌었습니다. 또한 문법적인 면에서도 차이가 있습니다.
팀블로그에 예전에 RValue Reference와 lambda 관련 글을 올린 적이 있는데 책을 만들 때 제가 올린 글을 다시 보니 설명에 미흡한 부분이 많아서
RValue Reference와 lambda는 새로 적었습니다. 그러니 RValue Reference와 lambda를 공부할 때는 꼭 블로그에 올라와 있는 글보다 이 책의 글을 보시기 바랍니다.
암묵적으로 스케줄러를 정의했다면 프로그램이 종료될 때 깔끔하게 해제되어야 하는데 이 부분이 매끄럽지 못한 것이
아쉽습니다. 현재 관련 개발자는 이번 버전에서는 깔끔하게 처리하는 부분을 미처 넣지 못했지만 꼭 다음
버전(VC++ 11)에서는 꼭 해결하겠다고 이야기 합니다.
ps : 이것은 4월15일 세션에서 제가 언급하였습니다만 블로그에는 포스팅을 늦게 하게 되었습니다.
VC++ 10은 C++0x나
병렬 프로그래밍 라이브러리 이외에도 툴적인 측면에서도 여러 좋은 기능들이 추가 되었습니다. 알고 있으면
작업할 때 편리한데 시간이 부족하여 제가 아직 자세하게 찾아보지 못해서 소개하지 못한 것이 많이 아쉽습니다. 그래서
짥은 것이라도 틈틈이 시간나면 소개하려고 합니다.
이미 알고 계시겠지만 Visual Stuido 2010 Beta2에
새로운 C++0x 기능이 추가 되었습니다.
추가된 것은nullptr이라는 키워드 입니다.
nullptr은 C++0x에서
추가된 키워드로 널 포인터(Null Pointer)를 나타냅니다.
null_ptr이
필요한 이유
C++03까지는 널 포인터를 나타내기 위해서는 NULL 매크로나 상수 0을 사용하였습니다.
그러나 NULL 매크로나 상수 0을
사용하여 함수에 인자로 넘기는 경우 int 타입으로 추론되어 버리는 문제가 발생 합니다.
< List 1 >
#include <iostream>
using namespace std;
void func( int a )
{
cout << "func
- int " << endl;
}
void func( double *p )
{
cout << "func
- double * " << endl;
}
int main()
{
func( static_cast<double*>(0)
);
func( 0 );
func( NULL
);
getchar();
return 0;
}
< 결과 >
첫
번째 func 호출에서는 double* 로 캐스팅을 해서
의도하는 func이 호출 되었습니다. 그러나 두 번째와 세
번째 func 호출의 경우 func( doube* p ) 함수에
널 포인터로 파라미터로 넘기려고 했는데 의도하지 않게 컴파일러는 int로 추론하여 func( int a )가 호출 되었습니다.
바로 이와 같은 문제를 해결하기 위해서 nullptr 이라는 키워드가
생겼습니다.
nullptr 구현안
C++0x에서 nullptr의
드래프트 문서를 보면 nullptr은 아래와 같은 형태로 구현 되어 있습니다.
const class {
public:
template <class T>
operator T*() const
{
return 0;
}
template <class C, class T>
operator T C::*() const
{
return 0;
}
private:
void operator&() const;
} nullptr = {};
nullptr 사용 방법
사용방법은 너무 너무 간단합니다. ^^
그냥 예전에 널 포인터로 0 이나 NULL을 사용하던 것을 그대로 대처하면 됩니다.
char* p = nullptr;
<List1>에서 널 포인트를 파라미터로 넘겨서 func( double* p )가 호출하게 하기 위해서는
func( nullptr );
로 호출하면 됩니다.
nullptr의 올바른 사용과 틀린 사용 예
올바른 사용
char* ch = nullptr; // ch에 널 포인터 대입.
sizeof( nullptr ); // 사용 할 수 있습니다. 참고로
크기는 4 입니다.
typeid( nullptr ); // 사용할 수 있습니다.
throw
nullptr; // 사용할 수 있습니다.
틀린 사용
int n = nullptr; // int에는 숫자만 대입가능한데
nullptr은 클래스이므로 안됩니다.
Int n2 = 0
if( n2 == nullptr ); // 에러
if( nullptr ); // 에러
if( nullptr == 0 ); // 에러
nullptr = 0; // 에러
nullptr + 2; // 에러
nullptr 너무 간단하죠? ^^
VC++ 10에서는 예전처럼 널 포인터를 나타내기 위해서 0 이나 NULL 매크로를 사용하지 말고 꼭nullptr을 사용하여 함수나 템플릿에서 널
포인터 추론이 올바르게 되어 C++을 더 효율적으로 사용하기 바랍니다.^^
짜투리 이야기...... ^^
왜 nullptr 이라고
이름을 지었을까?
nullptr을 만들 때 기존의 라이브러리들과 이름 충돌을 최대한
피하기 위해서 구글로 검색을 해보니 nullptr로 검색 결과가 나오는 것이 별로 없어서 nullptr로 했다고 합니다.
제안자 중 한 명인 Herb Sutter은 현재 Microsoft에서 근무하고 있는데 그래서인지 C++/CLI에서는
이미 nullptr 키워드를 지원하고 있습니다.
C++0x 이야기
근래에 Boost 라이브러리의
thread 라이브러리가 C++0x에 채택 되었다고 합니다.
Boost에 있는 많은 라이브러리가 C++0x에 채택되고 있으므로 컴파일러에서 아직 지원하지
않는 C++0x의 기능을 먼저 사용해 보고 싶다면 꼭 Boost 라이브러리를
사용해 보기 바랍니다.
concurrent_queue는 사용 용도가 concurrent_vector 보다 더 많을 것 같아서 좀 더 자세하게 설명하겠습니다.
온라인 서버 애플리케이션의 경우 ‘Producer-Consumer 모델’이나 이와 비슷한 모델로 네트웍을 통해서 받은 패킷을 처리합니다. 즉
스레드 A는 네트웍을 통해서 패킷을 받으면 Queue에 넣습니다. 그리고 스레드 B는 Queue에서
패킷을 꺼내와서 처리합니다. 이 때 Queue는 스레드 A와 B가 같이 사용하므로 공유 객체입니다. 공유 객체이므로 패킷을 넣고 뺄 때 크리티컬섹션과 같은 동기 객체로 동기화를 해야 합니다. 이런 곳에 concurrent_queue를 사용하면 아주 좋습니다.
concurrent_queue를
사용하기 위한 준비 단계
너무 당연하듯이 헤더 파일과 네임스페이스를 선언해야 합니다.
헤더파일
#include <concurrent_queue.h>
네임스페이스
using namespace Concurrency;
을 선언합니다.
이제 사전 준비는 끝났습니다. concurrent_queue를 선언한
후 사용하면 됩니다.
concurrent_queue< int > queue1;
concurrent_queue에 데이터 추가
concurrent_queue에 새로운 데이터를 넣을 때는push라는 멤버를 사용합니다.
원형
void
push( const _Ty& _Src );
STL의 deque의 push_back과 같은 사용 방법과 기능도 같습니다. 다만 스레스
세이프 하다는 것이 다릅니다. concurrent_queue는 앞 회에서 이야기 했듯이 스레드 세이프한
컨테이너이므로 제약이 있습니다. 그래서 deque 와
다르게 제일 뒤로만 새로운 데이터를 넣을 수 있습니다.
concurrent_queue< int > queue1;
queue1.push( 11 );
concurrent_queue에서
데이터 가져오기
데이터를 가져올 때는try_pop멤버를 사용합니다. 앞의 push의 경우는 STL의 deque와 비슷했지만 try_pop은 꽤 다릅니다.
원형
bool
try_pop( _Ty& _Dest );
try_pop을 호출 했을 때
concurrent_queue에 데이터가 있다면 true를 반환하고 _Dest에 데이터가 담기며 concurrent_queue에 있는
해당 데이터는 삭제됩니다. 그러나 concurrent_queue에
데이터가 없다면 false를 즉시 반환하고 _Dest에는
호출했을 때의 그대로 됩니다.
concurrent_queue< int > queue1;
queue1.push( 12 );
queue1.push( 14 );
int Value = 0;
if( queue1.try_pop( Value ) )
{
//
queue1에서 데이터를 가져왔음
}
else
{
//
queue1은 비어 있었음.
}
concurrent_queue가
비어 있는지 검사
concurrent_queue가 비어 있는지 알고 싶을 때는empty()를 사용합니다. 이것은
STL의 deque와 같습니다.
원형
bool
empty() const;
비어 있을 때는 true를 반환하고 비어 있지 않을 때는 false를 반환합니다. 다만
empty를 호출할 때 비어 있는지 검사하므로 100% 정확하지 않습니다. 100% 정확하지 않다라는 것은 empty와 push, try_pop 이 셋은 스레드 세이프하여 동시에 사용될 수 있으므로
empty를 호출할 시점에는 데이터가 있어서 false를 반환했지만 바로 직후에 다른 스레드에서 try_pop으로 삭제를 해버렸다면 empty 호출 후 false를 반환했어 try_pop을 호출했는데 false가 반환 될 수 있습니다.
concurrent_queue에 있는 데이터의 개수를 알고 싶을 때
concurrent_queue에 있는 데이터의 개수를 알고 싶을 때는unsafe_size멤버를 사용합니다.
원형
size_type
unsafe_size() const;
이것은 이름에서도 알 수 있듯이 스레드 세이프 하지 않습니다. 그래서 unsafe_size를 호출할 때 push나 try_pop이 호출되면 unsafe_size를 통해서 얻은 결과는
올바르지 않습니다.
concurrent_queue에
있는 데이터 순차 접근
concurrent_queue에 있는 데이터를 모두 순차적으로 접근하고
싶을 때는unsafe_begin과 unsafe_end를
사용합니다.
원형
iterator
unsafe_begin();
const_iterator
unsafe_begin() const;
iterator
unsafe_end();
const_iterator
unsafe_end() const;
unsafe_begin을 사용하여 선두 위치를 얻고, unsafe_end를 사용하여 마지막 다음 위치(미 사용 영역)를 얻을 수 있습니다. 이것도 이름에 나와 있듯이 스레드 세이프 하지
않습니다.
모든 데이터 삭제
모든 데이터를 삭제할 때는clear를 사용합니다. 이것은 이름에 unsafe라는 것이 없지만 스레드 세이프 하지 않습니다.
원형
template< typename _Ty, class _Ax >
void
concurrent_queue<_Ty,_Ax>::clear();
제 글을 보는 분들은 C++을 알고 있다는 가정하고 있기 때문에 STL을 알고 있다고 생각하여 아주 간단하게 concurrent_queue를
설명 하였습니다.
concurrent_queue 정말 간단하지 않습니까? 전체적으로 STL의 deque와
비슷해서 어렵지 않을 것입니다. 다만 스레드 세이프 하지 않은 것들이 있기 때문에 이것들을 사용할 때는
조심해야 된다는 것만 유의하면 됩니다.
이것으로 Concurrency Runtime의 PPL에 대한 설명은 일단락 되었습니다.
이후에는 Concurrency Runtime의 다른 부분을 설명할지
아니면 Beta2에서 새로 추가된 C++0x의 기능이나 또는
이전에 설명한 것들을 더 깊게 설명할지 고민을 한 후 다시 찾아 뵙겠습니다.^^
concurrent_queue는 queue
자료구조와 같이 앞과 뒤에서 접근할 수 있습니다.
concurrent_queue는 스레드
세이프하게 enqueue와 dequeue(queue에 데이터를
넣고 빼는) 조작을 할 수 있습니다.
또 concurrent_queue는 반복자를 지원하지만 이것은 스레드
세이프 하지 않습니다.
concurrent_queue와 queue의 차이점
concurrent_queue와 queue는 서로 아주 비슷하지만 다음과 같은 다른 점이 있습니다.
( 정확하게는 concurrent_queue와 STL의 deque와의 차이점 이라고 할수 있습니다. )
- concurrent_queue는
enqueue와 dequeue 조작이 스레드 세이프 하다.
- concurrent_queue는 반복자를 지원하지만 이것은 스레드
세이프 하지 않다.
- concurrent_queue는
front와 pop 함수를 지원하지 않는다.
대신에 try_pop 함수를 대신해서 사용한다.
- concurrent_queue는
back 함수를 지원하지 않는다.
그러므로 마지막 요소를 참조하는 것은 불가능하다.
- concurrent_queue는
size 메소드 대신 unsafe_size 함수를 지원한다.
unsafe_size는 이름 그대로 스레드 세이프 하지 않다.
스레드 세이프한 concurrent_queue의 함수
concurrent_queue에
enqueue 또는 dequeue 하는 모든 조작에 대해서는 스레드 세이프합니다.
- empty
- push
- get_allocator
- try_pop
empty는 스레드 세이프하지만 empty
호출 후 반환되기 전에 다른 스레드에 의해서 queue가 작아지던가 커지는 경우 이 동작들이
끝난 후에 empty의 결과가 반환됩니다.
스레드 세이프 하지 않은 concurrent_queue의 함수
- clear
- unsafe_end
- unsafe_begin
- unsafe_size
반복자 지원
앞서 이야기 했듯이 concurrent_queue는 반복자를 지원하지만
이것은 스레드 세이프 하지 않습니다. 그래서 이것은 디버깅 할 때만 사용할 것을 추천합니다.
또 concurrent_queue의 반복자는 오직 앞으로만 순회할 수
있습니다.
concurrent_queue는 아래의 반복자를 지원합니다.
- operator++
- operator*
- operator->
concurrent_queue는 앞서 설명한 concurrent_vector와 같이 스레드 세이프한 컨테이너지만 STL의 vector와 deque에는 없는 제약 사항도 있습니다. 우리들이 Vector와 deque를
스레드 세이프하게 래핑하는 것보다는 Concurrency Runtime에서 제공하는 컨테이너가 성능적으로
더 좋지만 모든 동작이 스레드 세이프하지 않고 지원하지 않는 것도 있으니 조심해서 사용해야 합니다.
다음에는 일반적인 queue에는 없고 concurrent_queue에서만 새로 생긴 함수에 대해서 좀 더 자세하게 설명하겠습니다.
ps : 앞 주에 Intel의 TBB에 대한 책을 보았습니다. 전체적으로 Concurrency Runtime과 비슷한 부분이 많아서 책을 생각 외로 빨리 볼 수 있었습니다. 제 생각에 TBB나 Concurrency Runtime를 공부하면 다른 하나도 아주 빠르고 쉽게 습득할 수 있을 것 같습니다.
shink_to_fit는 메모리 사용량과 단편화를 최적화 시켜줍니다. 이것은 메모리 재할당을 하기 때문에 요소에 접근하는 모든 반복자가 무효화됩니다.
Intel TBB
CPU로 유명한 Intel에서는
멀티코어 CPU를 만들면서 병렬 프로그래밍을 좀 더 쉽고, 안전화고, 확장성 높은 프로그램을 만들 수 있도록 툴과 라이브러리를 만들었습니다.
라이브러리 중 TBB라는 병렬 프로그래밍 용 라이브러리가 있습니다. 아마 TBB를 아시는 분이라면
Concurrent Runtime의 PPL에 있는 것들이
TBB에 있는 것들과 비슷한 부분이 많다라는 것을 아실 것입니다.
VSTS 2010 Beta2가 나온지 얼마 되지 않아서 병렬 컨테이너에
대한 문서가 거의 없습니다. 그러나 TBB에 관한 문서는
검색을 해보면 적지 않게 찾을 수 있습니다. concurrent_vector에 대해서 좀 더 알고 싶은
분들은 Intel의 TBB에 대해서 알아보시면 좋을 것 같습니다.
Visual Stuido 2010 Beta2가 나오면서 제가 기대하고
있었던 병렬 컨테이너가 드디어 구현되었습니다.
Concurrency Runtime(이하 ConRT)에는 총 3개의 병렬 컨테이너를 제공합니다. Beta2에서는 모두 다 구현되지는 못하고concurrent_vector와 concurrent_queue 두 개가 구현되었습니다. 아직 구현되지
않은 것은 concurrent_hash_map입니다.
세 개의 컨테이너들은 C++ STL의 컨테이너 중에서 가장 자주 사용하는
것으로 vector, deque, hash_map 컨테이너의 병렬 버전이라는 것을 이름을 보면 쉽게
알 수 있을 것입니다.
STL에 있는 컨테이너와 비슷한 이름을 가진 것처럼 사용 방법도 기존의
컨테이너와 비슷합니다. 다만 병렬 컨테이너 답게 스레드 세이프하며, 기존의
컨테이너에서 제공하는 일부 기능을 지원하지 못하는 제한도 있습니다.
몇 회에 나누어서 concurrent_vector와 concurrent_queue에 대해서 설명해 나가겠습니다.
이번에는 첫 번째로 concurrent_vector에 대한 것입니다.
concurrent_vector란?
STL의 vector와
같이 임의 접근이 가능한 시퀀스 컨테이너입니다. concurrent_vector는 멀티 스레드에서 요소를
추가하던가 특정 요소에 접근해도 안전합니다. 반복자의 접근과 순회는 언제나 멀티 스레드에서 안전해야
하므로 요소를 추가할 때는 기존의 인덱스와 반복자를 무효화 시키면 안됩니다.
concurrent_vector와
vector의 차이점
기능
vctor
Concurrent_vector
추가
스레드에 안전하지 않음
스레드에 안전
요소에 접근
스레드에 안전하지 않음
스레드에 안전
반복자 접근 및 순회
스레드에 안전하지 않음
스레드에 안전
push_back
가능
가능
insert
가능
불가능
clear
모두 삭제
모두 삭제
erase
가능
불가능
pop_back
가능
불가능
배열식 접근 예. &v[0]+2
가능
불가능
grow_by,
grow_to_at_least (vector의 resize와 비슷)는 스레드에 안전
추가 또는
resize 때 기존 인덱스나 반복자의 위치가 바뀌지 않음
bool 형은 정의 되지 않았음
concurrent_vector에 대한 설명을 이번에는 소개 정도로 끝내고 다음부터는 본격적으로 Concurrent_vector을 어떻게 사용하면 되는지 상세하게 설명해 나가겠습니다.^^
취소는 그것을 호출했을 때 즉시 동작하지 않습니다. task group이
취소되면 런타임은 각 task에 interruption point를
발동하여 런타임을 throw 시켜서 활동중인 task가 취소될
때 내부 예외 형을 잡을 수 있습니다. Concurrency Runtime은 런타임이 언제 interruption point를 호출할지 정의되어 있지 않습니다. 런타임이
취소를 할 때 던지는 예외를 잡아서 처리할 필요가 있습니다.
그래서 만약 task의 처리 시간이 긴 경우는 정기적으로 취소 여부를
확인할 필요가 있습니다.
< 리스트 4. >
auto t5 = make_task([&] {
//
Perform work in a loop.
for
(int i = 0; i < 1000; ++i)
{
// To reduce overhead, occasionally check for
// cancelation.
if ((i%100) == 0)
{
if (tg2.is_canceling())
{
wcout << L"The task was canceled." << endl;
break;
}
}
// TODO: Perform work here.
}
});
<리스트 4>의
굵게 표시한 코드는 task5가 정기적으로 task group인 tg2가 취소 되었는지 조사하고 있는 것입니다.
<리스트 4>는
명시적으로 task5가 속한 task group인 tg2가 취소되었는지 조사하고 있는데 만약 해당 task가 속한 task group을 직접적으로 호출하지 않고 취소 여부를 조사하고 싶을 때는 is_current_task_group_canceling()을 사용합니다.
4. 병렬 알고리즘에서의 취소
task group에서 사용하는 병렬 알고리즘도 위에서 소개한 방법으로
취소할 수 있습니다.
< 리스트 5. Task
group에서 병렬 알고리즘 사용 >
structured_task_group tg;
task_group_status status =
tg.run_and_wait([&] {
parallel_for(0, 100, [&](int i) {
// Cancel the task when i is 50.
if (i == 50)
{
tg.cancel();
}
else
{
// TODO: Perform work here.
}
});
});
// Print the task group status.
wcout << L"The task group status
is: ";
switch (status)
{
case not_complete:
wcout << L"not complete." << endl;
break;
case completed:
wcout << L"completed." << endl;
break;
case canceled:
wcout << L"canceled." << endl;
break;
default:
wcout << L"unknown." << endl;
break;
}
<리스트 5>는 task group인 tg에
task를 넣을 때 병렬 알고리즘을 넣었습니다. 그리고 이번 beta2에 새로 생긴run_and_wait멤버를 사용하여 task의 실행이 끝날 때 까지 대기하도록 했습니다(예전에는 run 이후에 wait를 호출해야 했습니다).
물론 cancel이 아닌 예외를 발생 시켜서 취소 시킬 수도 있습니다.
< 리스트 6. 병렬
알고리즘에서 예외를 발생시켜서 취소 시키기 >
try
{
parallel_for(0, 100, [&](int i) {
// Throw an exception to cancel the task when i is 50.
if (i == 50)
{
throw i;
}
else
{
// TODO: Perform work here.
}
});
}
catch (int n)
{
wcout << L"Caught " << n << endl;
}
<리스트 6>은 하나의 task만 예외를 발생시키고 있기 때문에 task group의 모든 task를 취소
시키기 위해서는 모든
task에서 예외를 발생시켜야 합니다.
그래서 아래의 <리스트 7>과 같이 전역 변수 flag를 사용합니다.
< 리스트 7. 모든 병렬 알고리즘의 task 취소 시키기 >
bool canceled
= false;
parallel_for(0,
100, [&](int i) {
// For illustration, set the flag to cancel
the task when i is 50.
if (i == 50)
{
canceled = true;
}
// Perform work if the task is not canceled.
if (!canceled)
{
// TODO: Perform work here.
}
});
5. Parallel 작업을 취소를 사용하지 못하는 경우
취소 작업은 모든 상황에서 다 사용할 수 있는 것은 아닙니다. 특정
시나리오에서는 사용하지 못할 수가 있습니다. 예를 들면 어떤 task는
활동중인 다른 task에 의해 block이 풀렸지만 아직
시작하기 전에 task group이 최소되어 버리면 계속 시작하지 못하여 결과적으로 애플리케이션이 dead lock 상황에 빠지게 됩니다.
이것으로 task group에서의 병렬 작업의 취소에 대한 것은 마칩니다. 다음에는 Beta2에 드디어 구현된 Concurrency Container에 대해서 설명하겠숩니다.
참고 url
MSDN : http://msdn.microsoft.com/en-us/library/dd984117(VS.100).aspx
task group을 사용하여 복수의 작업을 병렬적으로 처리할 때
모든 작업이 끝나기 전에 작업을 취소 해야 되는 경우가 있을 것입니다. task group에서
이와 같은 취소 처리를 어떻게 하는지 알아보겠습니다.
Concurrency Rumtime에 대한 정보는 아직까지는 MSDN을 통해서 주로 얻을 수 있기 때문에 거의 대부분
MSDN에 있는 것을 제가 좀 더 보기 좋고 쉽게 전달할 수 있도록 각색을 하는 정도이니 이미
MSDN에서 보신 분들은 pass 하셔도 괜찮습니다.^^;
1. 병렬 작업의 tree
PPL은 task group를
사용하여 병렬 작업을 세분화하여 각 작업을 처리합니다. 또 task
group에 다른 task group를 넣으면 이것을 부모와 자식으로 tree 구조로 표현할 수 있습니다.
< 리스트 1. >
structured_task_group tg1;
auto t1 = make_task([&] {
structured_task_group tg2;
//
Create a child task.
auto t4 = make_task([&] {
// TODO: Perform work here.
});
//
Create a child task.
auto t5 = make_task([&] {
// TODO: Perform work here.
});
//
Run the child tasks and wait for them to finish.
tg2.run(t4);
tg2.run(t5);
tg2.wait();
});
// Create a child task.
auto t2 = make_task([&] {
//
TODO: Perform work here.
});
// Create a child task.
auto t3 = make_task([&] {
//
TODO: Perform work here.
});
// Run the child tasks and wait for them to
finish.
tg1.run(t1);
tg1.run(t2);
tg1.run(t3);
<리스트 1>에서는 structured_task_group tg2가 tg1에 들어가서 tg2는 tg1의 자식이 되었습니다.
이것을 tree 그림으로 표현하면 아래와 같습니다.
< 그림 1. >
2. 병렬
작업의 취소 방법
parallel task를 취소할 때는task group의 cancel 멤버를 사용하면 됩니다(task_group::cancel, structured_task_group::cancel). 또 다른 방법으로는
task에서 예외를 발생시키는 것입니다. 두 가지 방법 중 cancel 멤버를 사용하는 것이 훨씬 더 효율적입니다.
cancel을 사용하는 것을 top-down
방식으로 task group에 속한 모든 task를
취소시킵니다. 예외를 발생 시켜서 취소하는 방법은 bottom-up 방식으로 task group에 있는 각 task에서 예외를 발생시켜서 위로
전파시킵니다.
cancel 멤버는 자식 task에서만
영향을 끼칩니다. 예를 들면 <그림 1>의t4에서 tg2를 cancel하면 tg2에 속한 t4, t5 task만 취소됩니다. 그러나tg1을 cancel하면
모든 task가 취소됩니다.
structured_task_group은 thread 세이프 하지 않기 때문에 자식 task에서 cancel을 호출하면 어떤 행동을 할지 알 수 없습니다. 자식 task는 cancel로 부모
task를 취소하던가 is_canceling로 취소 여부를 조사할 수 있습니다.
< 리스트 2. cancel을
사용하여 취소 >
auto t4 = make_task([&] {
//
Perform work in a loop.
for (int i = 0; i < 1000; ++i)
{
// Call a function to perform work.
// If the work function fails, cancel all tasks in the tree.
bool succeeded = work(i);
if (!succeeded)
{
tg1.cancel();
break;
}
}
});
2.2. 예외를 발생시켜 병렬 작업 취소
앞서 cancel 멤버를 사용하는 것 이외에 예외를 발생시켜서 취소
시킬 수 있다고 했습니다. 그리고 이것은 cancel()을
사용하는 것보다 효율이 좋지 않다고 했습니다.
예외를 발생시켜서 취소하는 방법의 예는 아래의 <리스트 3>의 코드를 보시면 됩니다.
< 리스트 3. 예외를
발생시켜서 취소 >
structured_task_group tg2;
// Create a child task.
auto t4 = make_task([&] {
//
Perform work in a loop.
for (int i = 0; i < 1000; ++i)
{
// Call a function to perform work.
// If the work function fails, throw an exception to
// cancel the parent task.
bool succeeded = work(i);
if (!succeeded)
{
throw exception("The task failed");
}
}
});
// Create a child task.
auto t5 = make_task([&] {
//
TODO: Perform work here.
});
// Run the child tasks.
tg2.run(t4);
tg2.run(t5);
// Wait for the tasks to finish. The
runtime marshals any exception
// that occurs to the call to wait.
try
{
tg2.wait();
}
catch (const exception& e)
{
wcout << e.what() << endl;
}
task_group이나
structured_task_group의 wait는 예외가 발생했을 때는 반환 값을 표시하지
못합니다. 그래서 <리스트 3>의 아래 부분에서 try-catch에서 exception을 통해서 상태를 표시하고 있습니다.