Thread 

스레드 생성 시 반복문의 인덱스를 매개변수로 받을 때


스레드(Thread)를 생성할 때, 반복문으로 여러 개의 스레드를 생성하면서 그 반복문의 인덱스를 매개변수로 전달하는 방법을 쓸 때가 있다.


class ThreadTestProgram

{

    public static int DeviceNum = 10;


    public static void Main(string[] args)

    {

        for (int i = 0; i < DeviceNum; i++)

        {

            new Thread(() => Run(i)).Start();

        }

    }


    public static void Run(int idx)

    {

        // 디바이스 인덱스에 따라서 스레드 별로 각 디바이스와 연결하는 작업...

        Console.WriteLine(idx);

    }

}

 

위의 예시 코드가 바로 그것이다. 여러 개의 디바이스에 연결해서 스레드로 작업을 처리해야 할 때의 코드인데, 스레드 함수에서는 반복문에서 디바이스의 인덱스를 전달받아서 연결하도록 설계된 코드이다.


물론 스레드이기 때문에 실행 순서 자체는 보장할 수 없지만, 적어도 각 스레드가 매개변수의 값으로 0, 1, 2, 3, 4, 5, 6, 7, 8, 9를 전달받는 것을 기대하고 설계된 코드라고 볼 수 있다.


 

하지만 실행결과를 보면 각 스레드가 전달받은 매개변수 값은 1, 2, 3, 4, 5, 5, 6, 8, 8, 10으로 0, 7, 9를 전달받은 스레드는 없고 5와 8을 전달받은 스레드는 두 개씩 있는 엉망진창인 상태인 것을 볼 수 있다.


이 상황이 의미하는 것은 스레드의 매개변수로 넣은 반복문의 인덱스 값이 스레드가 시작되기 전에 변경되면 스레드의 매개변수 값 역시 영향을 받는다는 것이다.


// int i = 0 -> 반복문에 사용될 인덱스 값 설정

for(int i = 0; i < DeviceNum; i++)

{// i < DeviceNum -> 인덱스 값이 반복문 내의 코드 블럭을 실행하기에 유효한지 검사

    new Thread(() => Run(i)).Start(); // 스레드 생성 

    // i 값이 증가하기 전에 스레드가 시작되면 원래 값이 들어간다.

}// i++ 값 증가 // i 값이 증가한 이후에 스레드가 시작되면 i + 1 값이 들어간다.


각 코드 진행 상황에 대한 해설을 달자면 위와 같다. i값이 증가한 이후에 스레드가 시작되는 것이 문제로 스레드가 시작되기 전까지 전달되는 값이 변하지 않을 것에 대한 보장이 필요한 상태이다.


이를 위해서 코드를 다음과 같이 변경해보자.


class ThreadTestProgram

{

    public static int DeviceNum = 10;


    public static void Main(string[] args)

    {

        for (int i = 0; i < DeviceNum; i++)

        {

            int idx = i; // i 값이 바뀌어도 상관없도록 임시 변수에 값을 전달하여 스레드의 매개 변수로 사용

            new Thread(() => Run(idx)).Start();

        }

    }


    public static void Run(int idx)

    {

        // 디바이스 인덱스에 따라서 스레드 별로 각 디바이스와 연결하는 작업...

        Console.WriteLine(idx);

    }

}

 

위의 임시 코드처럼 i의 값을 임시 변수에 전달해서 스레드에 매개변수로 전달하면 i값이 증가해도 idx의 값은 증가하지 않기 때문에 스레드가 실행될 때까지 값이 변조되지 않을 것이다.


 

실제로 코드를 컴파일해보면 실행순서는 섞여있지만 각 스레드가 디바이스 인덱스로 0, 1, 2, 3, 4, 5, 6, 7, 8, 9를 받은 것을 확인할 수 있다.

반응형

Thread 

여러 작업을 동시 처리하기


일반적으로 우리가 사용하는 운영체제(Operation System, OS)은 멀티 태스크를 지원한다. 그 덕분에 우리는 구글에서 자료를 찾으면서, 유튜브에서 강좌를 듣고, 동시에 비주얼 스튜디오에서 작업을 할 수 있으며 그와 동시에 오디오 재생 프로그램을 통해서 음악을 들을 수 있다. 이때 구글과 유튜브에 접속할 수 있게 해주는 브라우저, 코드 작업을 하는 비주얼 스튜디오, 음악을 재생한느 오디오 재생 프로그램이 각각 하나의 프로세스(Process)이다.


또 여기서 이 프로세스는 하나 이상의 스레드(Thread)로 이루어진다. 스레드는 프로세스를 여러 개의 조각으로 나눈 것으로, 한 OS에서 여러 프로세스가 작업하는 것처럼, 한 프로세스에서 여러 스레드가 동시에 작업을 처리할 수 있게 해준다. 방금 앞에서 든 예시 중에 오디오 재생 프로그램을 예시로 들자면, 오디오 프로그램은 하나의 프로세스으로, 그 안에서 여러 스레드로 나뉘어서 한 스레드는 음악을 재생하고, 또 다른 스레드는 가사를 보여주면서 음악 재생 시간에 맞춰서 싱크를 맞추는 등의 방식으로 동시에 여러 가지 작업을 동시에 처리하는 것이다.



스레드 생성/시작하기


그럼 이 스레드를 사용하기 위한 방법을 차근차근 배워보자.


using System.Threading;


스레드에 관련된 기능들은 System.Threading 네임스페이스에 포함되어 있다. System.Threading.* 처럼 일일이 네임스페이스를 입력해서 코드를 작성해줄 수도 있지만 가독성 문제와 작업 효율성을 위해서 using 선언을 해주자.


using System;

using System.Threading;


namespace ThreadTest

{

    class ThreadTestProgram

    {

        public static void Main(string[] args)

        {

            Run(0);

            Run(1);

        }


        public static void Run(int idx)

        {

            Console.WriteLine(string.Format("Run {0} Start"idx));

            for (int i = 0; i < 10; i++)

            {

                Console.WriteLine(string.Format("Run {0} :: {1}"idx, i));

            }

            Console.WriteLine(string.Format("Run {0} End"idx));

        }

    }

}


우선 스레드를 사용하지 않는 경우의 코드를 먼저 확인해보자. 위의 코드는 스레드를 전혀 사용하지 않고 Run() 함수가 두 번 연속 호출된다. 


 

이렇게 스레드를 사용하지 않고 Run() 함수를 두 번 호출하면 모두가 알다시피 코드는 순차적으로 진행해서 첫 번째 Run(0) 함수가 완전히 끝난 후에야 두 번째 Run(1) 함수가 동작한다.


using System;

using System.Threading;


namespace ThreadTest

{

    class ThreadTestProgram

    {

        public static void Main(string[] args)

        {

            Thread thread = new Thread(() => Run(0));

            thread.Start();

            Run(1);

        }


        public static void Run(int idx)

        {

            Console.WriteLine(string.Format("Run {0} Start"idx));

            for (int i = 0; i < 100; i++)

            {

                Console.WriteLine(string.Format("Run {0} :: {1}"idx, i));

            }

            Console.WriteLine(string.Format("Run {0} End"idx));

        }

    }

}


이번에는 스레드를 생성해서 첫 번째 Run(0) 함수를 스레드로 호출하게 했다. 그리고 반복문 10회로는 동시 실행을 판별하기 어려워서 반복 횟수를 100회로 늘렸다.


 

스레드를 사용한 후의 실행결과는 어느 함수가 끝나기 전에 두 함수가 동시에 진행되고 있음을 충분히 알 수 있다.


Thread thread = new Thread(() => Run(0));

thread.Start();

 

스레드를 사용하는 방법은 간단하게 Thread 객체를 생성하고 생성자의 매개변수로 스레드로 돌리고자 하는 함수를 넣어준 뒤 Start() 함수를 호출하면 된다. 스레드를 생성하기만 하고 Start() 함수를 호출하지 않으면 그 스레드는 동작하지 않는다.



스레드 양보하기


위의 스레드 실행 예시 이미지를 보면 스레드가 몇 번의 연산을 처리하고 잠시 다른 스레드에 처리 시간을 넘겨주고 다시 돌려받는 것을 알 수 있다. 스레드 프로그래밍에서는 이런 CPU 점유 상태를 다른 스레드에 언제 얼마동안 양보할 지를 알리는 함수가 있는데 이것이 바로 Thread.Sleep() 함수다.


Thread.Sleep(10);


Thread.Sleep() 함수는 해당 함수를 호출한 스레드가 매개변수의 시간만큼 쉬면서 다른 스레드에 처리 우선권을 양보하게 만든다. 매개변수의 시간 단위는 밀리세컨드(Milisecond)로 1000분의 1초에 해당한다. 즉 위 코드에 적힌 시간으로는 0.001초 동안 다른 스레드에 처리 우선권을 양보한다는 의미이다.


using System;

using System.Threading;


namespace ThreadTest

{

    class ThreadTestProgram

    {

        public static void Main(string[] args)

        {

            Thread thread0 = new Thread(() => Run(0));

            thread0.Start();

            Thread thread1 = new Thread(() => Run(1));

            thread1.Start();

        }


        public static void Run(int idx)

        {

            Console.WriteLine(string.Format("Run {0} Start"idx));

            for (int i = 0; i < 100; i++)

            {

                Console.WriteLine(string.Format("Run {0} :: {1}"idx, i));

                Thread.Sleep(10);

            }

            Console.WriteLine(string.Format("Run {0} End"idx));

        }

    }

}

 

이번에는 Run(0)와 Run(1) 함수를 모두 스레드로 호출했으며 반복문 중간에 Sleep() 함수를 추가했다.


 

이번 실행결과를 보면 Sleep() 함수를 사용하지 않을 때와는 다르게 허용된 시간에 최대한 몰아서 처리하지 않고 필요한 계산만 처리한 뒤에 바로 다른 스레드에게 처리 우선권을 넘기는 것을 확인할 수 있다.





스레드 중단하기


thread.Abort();

thread.Join();


작동 중인 스레드를 중지하는 방법은 두 가지가 있는데 Abort() 함수와 Join() 함수가 그것이다. 이 두 함수의 차이는 다음과 같다.


Abort() :: 함수의 종료를 보장하지 않고 어느 시점이던지 상관 없이 도중에 강제로 중단시킨다.

Join() :: 함수의 종료를 보장하며 스레드가 동작시키는 중인 함수의 끝에 도달하기를 기다린 다음에 스레드를 닫는다.


using System;

using System.Threading;


namespace ThreadTest

{

    class ThreadTestProgram

    {

        public static void Main(string[] args)

        {

            Thread thread0 = new Thread(() => Run(0));

            thread0.Start();

            Thread.Sleep(100);

            thread0.Abort();


            Thread thread1 = new Thread(() => Run(1));

            thread1.Start();

            Thread.Sleep(100);

            thread1.Join();

        }


        public static void Run(int idx)

        {

            Console.WriteLine(string.Format("Run {0} Start"idx));

            for (int i = 0; i < 100; i++)

            {

                Console.WriteLine(string.Format("Run {0} :: {1}"idx, i));

                Thread.Sleep(10);

            }

            Console.WriteLine(string.Format("Run {0} End"idx));

        }

    }

}


thread0은 Abort() 시키고 thread1은 Join() 시키는 코드를 작성한다음 컴파일 해보자.


 

Run(0)는 반복문이 동작하던 도중에 중단되고, Run(1)은 End까지 무사히 호출되고 종료된 것을 확인할 수 있다.


위듸 예시를 통해 알 수 있듯이 Abort() 함수의 경우에는 스레드를 작동 도중에 강제로 종료하기 때문에 스레드 강제 종료가 시스템에 심각한 영향을 끼치지 않는다는 보장이 있을 때만 사용하는 것이 좋다.


class ThreadTestProgram

{

    public static void Main(string[] args)

    {

        Thread thread0 = new Thread(() => Run(0));

        thread0.Start();

        Thread.Sleep(100);

        thread0.Abort();

    }


    public static void Run(int idx)

    {

        try

        {

            int runIdx = idx;

            Console.WriteLine(string.Format("Run {0} Start", runIdx));

            for (int i = 0; i < 100; i++)

            {

                Console.WriteLine(string.Format("Run {0} :: {1}", runIdx, i));

                Thread.Sleep(10);

            }

            Console.WriteLine(string.Format("Run {0} End", runIdx));

        }

        catch (Exception e)

        {

            Console.WriteLine(e);

        }

    }

}


스레드를 Abort() 함수로 강제 종료할 때 해당 스레드 함수에서는 System.Threading.ThreadAbortException이라는 예외를 발생시킨다. 만약 스레드를 Abort() 시켰을 때, 리소스 정리 등의 뒤처리 작업이 필요한 경우라면 반드시 해당 스레드 함수에서 발생하는 ThreadAbortException 예외를 받아서 정리 작업을 진행하는 것이 좋다.



스레드 동기화(Thread Synchronization)


여러 개의 스레드를 두고 작동하는 프로그램의 경우에, 여러 스레드가 자원이나 변수 등을 공유하는 경우가 많다. 다음의 예시를 보자.


class ThreadTestProgram

{

    public class Villige

    {

        public int population = 1000

            

        public void AddVillager()

        {

            population++;


            for(int i = 0; i < population; i++)

            {

                for(int j = 0; j < population; j++)

                {


                }

            }

            // 추가된 주민에게 주민번호 주기

            Console.WriteLine(string.Format("새 주민의 주민번호 :: {0}", population));

        }

    }


    public static void Main(string[] args)

    {

        Villige manager = new Villige();

        for(int i = 0; i < 10; i++)

        {

            new Thread(new ThreadStart(manager.AddVillager)).Start();

        }

    }

}


작은 마을을 키우는 게임을 만든다고 가정했을 때, 마을에 새로운 마을 주민이 태어나거나 새로 들어오면 인구 수를 늘려주고 몇 가지 처리를 한 뒤에 주민번호를 매겨주는 AddVillager() 함수를 구현했다. 그리고 주민번호는 고유한 번호이기 때문에 각 주민 마다 번호가 중복되어서는 안된다고 가정해보자. 이 때 마을 주민이 동시에 추가될 수도 있기 때문에 스레드 처리를 한다.


그런데 플레이 도중에 마을에 10명의 주민이 동시에 추가되었다고 해보자. 그러면 현재까지 1000명의 주민이 있었으니 그 뒤에 추가되는 주민들의 번호는 1001, 1002, 1003, ..., 1009, 1010이 되기를 기대할 것이다.


 

하지만 실행결과는 새 주민들의 주민번호가 중복되어서 발급되어 버렸다. 이러한 문제를 스레드 세이프 하지 않다(Not thread-safe)라고 하는데 이 문제를 해결하기 위해서 필요한 것이 바로 스레드 동기화이다. 스레드 동기화는 하나의 공용된 자원이나 변수에 여러 개의 스레드가 접근할 때, 스레드들이 순서를 지켜서 사용하고 다른 스레드가 사용 중일 때는 사용하지 못하게 만드는 것이다.


class ThreadTestProgram

{

    public class Vilige

    {

        public int population = 1000;


        public object populationLock = new object();


        public void AddHuman()

        {

            lock (populationLock)

            {

                population++;


                for (int i = 0; i < population; i++)

                {

                    for (int j = 0; j < population; j++)

                    {


                    }

                }

                // 추가된 주민에게 주민번호 주기

                Console.WriteLine(string.Format("새 주민의 주민번호 :: {0}", population));

            }

        }

    }


    public static void Main(string[] args)

    {

        Vilige manager = new Vilige();

        for(int i = 0; i < 10; i++)

        {

            new Thread(new ThreadStart(manager.AddHuman)).Start();

        }

    }

}


스레드를 동기화하는 방법은 lock을 사용사는 것이다. 스레드 락을 하기 위한 객체를 하나 만들어서 lock()을 해주면 lock() { } 으로 묶어준 블럭이 한 스레드에서 실행되는 동안에는 같은 객체의 lock으로 묶인 스레드는 멈춘 상태로 해당 코드를 진행하지 못하게 된다.


 

스레드를 lock() 함수로 동기화하여 실행하면 새로 들어온 주민들의 주민번호가 겹치지 않고 정상적으로 매겨지게 된다.


이런 스레드 동기화에도 단점은 있는데 스레드 동기화되는 부분은 동시 처리가 안되고 한 스레드씩 작업을 진행하기 때문에 프로그램의 속도가 느려질 수 있다.


 

그리고 스레드의 동기화 구조가 복잡한 경우라면, 위의 이미지처럼 두 개의 스레드가 두 자원을 사용하려고 할 때, 스레드 1이 자원 1을 사용하며 자원 2가 풀리기를 기다리고 있고 스레드 2가 자원 2를 사용하며 자원 1이 풀리기를 기다려서 두 스레드가 멈춰버리는 데드락(Dead lock, 교착상태)이 발생할 수도 있다.


이렇게 스레드는 동시 처리를 하기에 유용한 방법이지만, 호출 순서를 보장할 수 없고 디버깅이 어려운 구조이기 때문에 잘못 사용할 경우 해결하기 어려운 문제를 발생시키기 쉽다. 그러므로 스레드를 사용할 때는 조심해서 사용해야만 한다.

반응형
  1. 질문충 2020.09.26 22:40

    4스레드짜리 시피유로 24스레드까지 만들어도 돌아가는데 기계적인 부분과는 무관한건가요?

    • wergia 2020.10.20 00:05 신고

      네, CPU 사양으로 표시되는 코어나 스레드 수와는 무관하게 메모리가 허용하는 양만큼 스레드를 만들 수 있다고 하네요.

Multi thread 프로그래밍을 할때 현재 thread가 작업하는 메모리를 다른 thread에 덮어쓰거나 잘못 사용하는 일이 발생하지 않도록 주의해야한다.

이러한 문제를 대비하기 위한 기법이 바로 thread 동기화인데 Critical Section, Semaphore, Mutex 등이 있다.

이러한 것들을 사용하지 않는다면

 

Tread 동기화를 사용하지 않은 스레드 작업

#include <iostream>
#include <Windows.h>
#include <thread>
#include <memory>
#include <mutex>
using namespace std;
int iArr[500];      // 두 thread가 작업할 메모리 공간

void test1()    // test1 함수는 iArr에 0을 채운다.
{
    for (int i = 0; i < 500; i++)
    {
        iArr[i] = 0;
        Sleep(1);
    }
}

void test2()    // test2 함수는 iArr에 1을 채운다.
{
    for (int i = 0; i < 500; i++)
    {
        iArr[i] = 1;
        Sleep(1);
    }
}

int main()
{
    // 각 함수를 스레드로 작동
    thread t1(test1);
    thread t2(test2);

    // 두 함수가 끝나기를 기다린다.
    t1.join();
    t2.join();

    // 결과값 출력
    for (int i = 0; i < 500; i++)
    {
        cout << iArr[i];
        if ((i +1) % 50 == 0)
        {
            cout << endl;
        }
    }
}

 

 

위의 결과 처럼 중간 중간 값을 덮어씌워서 오작동을 일으킬 수 있다.

 

하지만 위에서 말한 thread 동기화 기법을 사용하면 이 문제는 해결된다.

 

 

Mutex를 사용한 thread 작업

#include <iostream>
#include <Windows.h>
#include <thread>
#include <memory>
#include <mutex>
using namespace std;

int iArr[500];
mutex m;    // Thread Lock을 걸 Mutex 클래스

void test1()
{
    m.lock();   // 메모리에 lock을 걸어 다른 thread에서 사용하지 못하게 한다.
    for (int i = 0; i < 500; i++)
    {
        iArr[i] = 0;
        Sleep(1);
    }
    m.unlock(); // 메모리에 대한 작업이 끝난 이후에 lock을 해제한다.
}

void test2()
{
    m.lock();
    for (int i = 0; i < 500; i++)
    {
        iArr[i] = 1;
        Sleep(1);
    }
    m.unlock();
}

int main()
{
    // 각 함수를 스레드로 작동
    thread t1(test1);
    thread t2(test2);

    // 두 함수가 끝나기를 기다린다.
    t1.join();
    t2.join();

    // 결과값 출력
    for (int i = 0; i < 500; i++)
    {
        cout << iArr[i];
        if ((i +1) % 50 == 0)
        {
            cout << endl;
        }
    }
}

 

 

Mutex를 사용하면 처음 lock을 건 thread에서 작업이 끝난 이후에야 다른 thread에서 그 메모리에 접근해서 작업이 가능하기 때문에 모든 배열에 1이 출력이 된다.

 

 

 

이렇게 별 문제가 없어 보이는 thread lock에는 문제가 있는데 바로 Race Condition이다. 이 race condition은 일반적으로 1번 thread와 2번 thread가 있고 데이터가 담긴 메모리 A, B가 있을 때,  1번 thread가 A메모리에 lock을 건 상태에서 B메모리에 작업을 하려고 하고, 2번 thread는 B메모리에 lock을 건 상태에서 A메모리에 작업을 하려고 할때 발생한다. 이렇게 되면 1번 thread와 2번 thread는 서로 자신의 작업이 끝나야 각각의 메모리의 lock 해제하는데 서로에게 lock이 걸린 메모리 때문에 작업을 그 이후로 진행할 수 없기 때문에 프로그램은 작동을 정지하고 만다. 이런 상황을 다른 말로 dead lock, 교착상태라고도 한다.

 

하지만 scoped lock은 사실 이런 완벽한 교착 상태를 예방하기 위한 것이라기 보다는 사소한 프로그래머의 실수를 방지하기 위한 것이다. 다음의 코드를 보자.

 

프로그래머의 실수!

#include <iostream>
#include <Windows.h>
#include <thread>
#include <memory>
#include <mutex>
using namespace std;
int iArr[500];
mutex m;
void test1()
{

    // 이 프로그래머는 훌륭하게 lock을 걸었지만
    m.lock();
    for (int i = 0; i < 500; i++)
    {
        iArr[i] = 0;
        Sleep(1);
    }
    // unlock을 까먹고 하지 않았다!
}

void test2()
{
    m.lock();
    for (int i = 0; i < 500; i++)
    {
        iArr[i] = 1;
        Sleep(1);
    }
    m.unlock();
}

int main()
{
    thread t1(test1);
    thread t2(test2);
    t1.join();
    t2.join();
    for (int i = 0; i < 500; i++)
    {
        cout << iArr[i];
        if ((i +1) % 50 == 0)
        {
            cout << endl;
        }
    }
}

 

위의 코드처럼 실수로 thread lock을 건 이후에 작업이 끝나고 unlock을 까먹는다면 2번 thread는 1번 thread가 걸어둔 lock이 해제되기를 영원히 기다릴 것이다. 위 코드처럼 간단한 thread 예제라면 이러한 문제를 쉽게 찾아내겠지만 수만줄을 넘어가고 여러 종류의 thread가 여러 개 돌아가는 커다란 프로그램에서 저런 기억하기 어려운 사소한 실수를 범하게 된다면 얼마나 긴 시간을 허비하게 될까?

 

 

 

그래서 나온 것이 scoped lock이라는 것인데 이것의 원리는 매우 간단하다. 우리가 사용하는 Mutex를 하나의 클래스로 가볍게 감싸는 것이다. 그리고 클래스의 생성자가 실행될 때 lock()을 실행하고 클래스의 소멸자가 실행될 때 unlock()을 실행해 주는 것이다.

이것은 프로그래머가 직접 구현해도 될 정도로 간단한 작업이지만 표준 C++에서는 이미 지원되고 있으니 해당 기능을 사용하면 된다.

 

Scoped Lock 사용법

#include <iostream>
#include <Windows.h>
#include <thread>
#include <memory> // Scoped lock 기능을 사용하기 위해 include 해야하는 header
#include <mutex>
using namespace std;
int iArr[500];
mutex m;
void test1()
{
    lock_guard<mutex> g(m);      
    // lock_guard 클래스의 템플릿에 mutex 클래스를 넣고 생성자에 우리가 사용하는 mutex 객체를 넣어주면 된다.
    // lock_guard 객체가 생성될 때 자동으로 thread에 lock이 걸린다.
    for (int i = 0; i < 500; i++)
    {
        iArr[i] = 0;
        Sleep(1);
    }
    // 그리고 함수가 끝날 때 or 스코프를 벗어날 때 자동으로 lock_guard 객체가 소멸되면서 thead lock이 해제된다.
}
void test2()
{
    lock_guard<mutex> g(m);
    for (int i = 0; i < 500; i++)
    {
        iArr[i] = 1;
        Sleep(1);
    }
}
int main()
{
    thread t1(test1);
    thread t2(test2);
    t1.join();
    t2.join();
    for (int i = 0; i < 500; i++)
    {
        cout << iArr[i];
        if ((i +1) % 50 == 0)
        {
            cout << endl;
        }
    }
}
반응형

'C++' 카테고리의 다른 글

[C++11] enum class  (0) 2017.07.17
[C++ 11] static_assert  (0) 2017.05.23
[C++ 11] Auto Vectorization  (1) 2016.11.01
[C++ 11] Range-Based For  (0) 2016.11.01
[C++ 11] Scoped Lock  (0) 2016.11.01

+ Recent posts