이번 포스팅에서는 boost에서 제공하는 strand에 대해 알아보는 시간을 갖도록 하겠습니다.

strand에 대한 설명은 How strands work and why you should use them 내용을 바탕으로 작성되었으니 참고부탁드리며, 글 하단에는 strand 동작을 검증하기 위한 예제 코드를 첨부하였으니 직접 테스트 해보시며 strand의 사용법과 필요성에 대해 다시 한번 정리하는 시간을 갖으면 좋을것같네요😁


목차

  1. 1. How strands work and why you should use them
    1. 1.1. Possible scenario
    2. 1.2. Cache locality
  2. 2. 예제 코드
  3. 3. 마치며

How strands work and why you should use them

만약 여러분이 Boost Asio를 사용해본적이 있다면, 틀림없이 strands 라는 것을 사용해봤거나 최소한 마주치기라도 해봤을 것입니다.

If you ever used Boost Asio, certainly you used or at least looked at strands.

strands 사용을 통해 얻을 수 있는 주요한 이점은 여러분의 소스 코드를 단순화해준다는 것입니다. 이러한 단순화는 strand를 통해 실행되는 핸들러에 대해서는 명시적인 동기화가 필요하지 않기때문에 가능합니다. strand는 2개의 핸들러가 동시에 실행되지않음을 보장합니다.

The main benefit of using strands is to simplify your code, since handlers that go through a strand don’t need explicit synchronization. A strand guarantees that no two handlers execute concurrently.

만약 여러분이 단지 하나의 I/O 쓰레드(boost 라이브러리 측면에서 설명하자면 io_context::run()을 호출하는 쓰레드가 오직 하나인 경우를 의미함)를 사용한다면, 동기화를 걱정할 필요가 전혀 없습니다. 그것이 이미 암묵적인 strand입니다. 하지만 여러분이 더 많은 I/O 쓰레드를 사용해야하는 순간이 온다면 I/O 쓰레드에서 실행되는 핸들러에 대해 명시적인 동기화 처리를 하거나 strands를 사용해야합니다.

If you use just one I/O thread (or in Boost terms, just one thread calling io_context::run), then you don’t need synchronization anyway. That is already an implicit strand. But the moment you want to ramp up and have more I/O threads, you need to either deal with explicit synchronization for your handlers, or use strands.

여러분이 정의한 핸들러를 명시적으로 동기화하는 것은 명백히 가능한 일이지만 이것은 불필요하게 여러분의 소스 코드에 복잡성을 부여하여 버그를 발생시킬 수도 있습니다. 핸들러의 명시적 동기화가 갖는 또 다른 영향도 중 하나는 여러분이 정말 신중하게 고려하지 않는 이상 불필요한 블록 구간의 발생이 불가피하다는 점입니다.

Explicitly synchronizing your handlers is certainly possible, but you will be unnecessarily introducing complexity to your code which will certainly lead to bugs. One other effect of explicit handler synchronization is that unless you think really hard, you’ll very likely introduce unnecessary blocking.

strands는 여러분의 소스 코드와 핸들러의 실행 사이에 또 다른 계층을 하나 둠으로써 동작합니다. 여러분의 핸들러를 직접적으로 실행하는 worker 쓰레드들을 갖는 대신에, 이러한 핸들러들이 strand 내의 큐에 저장됩니다. 그리고나서 strand는 모든 조건이 충족되었을 때에만 핸들러를 실행하도록 하는 제어 권한을 갖게됩니다.

Strands work by introducing another layer between your application code and the handler execution. Instead of having the worker threads directly execute your handlers, those handlers are queued in a strand. The strand then has control over when executing the handlers so that all guarantees can be met.

이것에 대해 생각해볼 수 있는 한가지 방법은 아래 그림과 같습니다.

One way you can think about is like this:

Possible scenario

I/O 쓰레드와 핸들러에 어떤 일이 발생하는지를 시각적으로 보여주기 위해 Remotry를 사용했습니다.

To visually demonstrate what happens with the I/O threads and handlers, I’ve used Remotery.

사용되는 코드는 4개의 worker 쓰레드와 8개의 connection을 에뮬레이트합니다. 5ms ~ 15ms 사이의 임의의 작업량을 갖는 핸들러(일종의 작업 항목)는 worker 큐에 놓이게됩니다. 실제로 여러분이 이러한 커넥션별 쓰레드비율 또는 핸들러 작업량을 갖지않더라도 해당 수치들은 문제를 보여주기에 용이합니다. 또한 저는 Boost Asio 라이브러리를 사용하지 않았고, 해당 글의 주제를 논의해나가기 위해 자체적으로 strand를 구현해보았습니다.

The code used emulates 4 worker threads, and 8 connections. Handlers (aka work items) with a random workload of [5ms,15ms] for a random connection are placed in the worker queue. In practice, you would not have this threads/connections ratio or handler workload, but it makes it easier to demonstrate the problem. Also, I’m not using Boost Asio at all. It’s a custom strand implementation to explore the topic.

아래 그림은 worker 쓰레드들의 상태를 보여줍니다:

So, a view into the worker threads:

Conn N은 커넥션 객체를 의미합니다(또는 worker 쓰레드 상에서 작업을 수행하는 어떠한 객체). 각각의 커넥션 객체는 서로 다른 색상을 갖고있습니다. 보시다시피 겉보기에는 괜찮아 보입니다. 이제 각각의 Conn 객체의 실직적인 상태를 시분할 단위로 살펴보도록 하겠습니다.

Conn N are our connection objects (or any object doing work in our worker threads for that matter). Each has a distinct colour. All good on the surface as you can see. Now lets look at what each Conn object is actually doing with its time slice.

worker 큐와 쓰레드는 핸들러가 수행하는 작업을 인지하지 못하기때문에서 worker 쓰레드는 처리할 커넥션 객체가 들어오는 순간 기꺼이 큐에서 꺼내려 할 것입니다. 하나의 쓰레드에서 또 다른 worker 쓰레드에서 이미 사용되고 있는 커넥션 객체를 실행하려고 할 때, 그것은 block 상태에 놓이게될 것입니다. 현재의 시나리오에서는 전체 시간의 약 19% 정도가 block 상태 또는 또 다른 오버 헤드로 인해 낭비되고 있습니다. 다시 말해, 실질적으로 worker 쓰레드가 작업에 할당하는 시간이 전체의 약 81% 뿐이라는 것입니다:

What is happening is the worker queue and worker threads are oblivious to what its handlers do (as expected), so the worker threads will happily dequeue work as it comes. One thread tries to execute work for a given Conn object which is already being used in another worker thread, so it has to block. In this scenario, ~19% of total time is wasted with blocking or other overhead. In other words, only ~81% of the worker thread’s time is spent doing actual work:

오버 헤드는 worker 쓰레드 전체 시간에서 실제 작업 시간을 뺀 값으로 측정됩니다. 그렇기 때문에 핸들러 내의 명시적인 동기화 그리고 worker 큐/쓰레드 내부적으로 수행된 모든 동기화 작업들이 이 오버 헤드에 포함됩니다.

NOTE: The overhead was measured by subtracting actual work time from the total worker thread’s time. So it accounts for explicit synchronization in the handlers and any work/synchronization done internally by the worker queue/threads.

이제 커넥션 객체의 작업을 동기화하기 위해 strands를 사용하는 경우의 상태 값을 살펴봅시다.

Lets see how it looks like if we use strands to serialize work for our Conn objects:

내부 작업 또는 동기화에 낭비되는 시간이 매우 작다는것을 알 수 있습니다.

Very little time is wasted with internal work or synchronization.


Cache locality

strands를 사용하는 경우에 얻을 수 있는 또 하나의 작은 이점은 더 나은 CPU 캐시 활용입니다. worker 쓰레드는 또 다른 커넥션 객체를 가지고 오기 전에 현재 주어진 커넥션 객체의 핸들러를 더 많이 실행하려는 경향이 있습니다.

Another possible small benefit with this scenario is better CPU cache utilization. Worker threads will tend to execute a few handlers for a given Conn object before grabbing another Conn object.

Zoomed out, without strands

Zoomed out, with strands
(각각의 쓰레드에 따라 동일한 번호의 커넥션 객체의 작업 수행이 밀집되어 있는 것을 볼 수 있음)


예제 코드

실제 예제 코드를 통해서 strand의 사용법을 알아보도록 하겠습니다. 아래 예제는 N개(1, 2, 4, 8, 16)의 쓰레드에서 각각 전역 변수인 sum에 2씩 더하는 연산을 수행하며, sum의 누적 합이 1억이 출력되는지를 확인하고자 하는 코드입니다.
쓰레드가 1개(N=1)인 상황에서 sum이 1억이 되기위해서는 2씩 더하는 연산이 총 50000000번 반복돼야하며 쓰레드를 16개(N=16)까지 늘린다면 해당 연산은 3125000번 반복되면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
#include <iostream>

#include <boost/asio.hpp>
#include <boost/thread.hpp>
#include <boost/thread/detail/thread_group.hpp>

int sum = 0;

class thread_func
{
public:
thread_func(int idx, boost::asio::io_context& ctx) : idx_(idx), ctx_(ctx)
{

}

void operator()()
{
std::ostringstream oss;
oss << std::this_thread::get_id();
printf("io_context::run #%d thread [%s]\n", idx_, oss.str().c_str());
ctx_.run();
}

private:
int idx_;
boost::asio::io_context& ctx_;
};

void make_sum_to_100_million_without_strand(int thread_count)
{
boost::asio::io_context ctx;

int loop_count = 0;
switch (thread_count)
{
case 1:
loop_count = 50000000;
break;
case 2:
loop_count = 25000000;
break;
case 4:
loop_count = 12500000;
break;
case 8:
loop_count = 6250000;
break;
case 16:
loop_count = 3125000;
break;
default:
break;
}

// no strand
for (int i = 0; i < thread_count; i++)
{
boost::asio::post(ctx, [&](){
std::ostringstream oss;
oss << std::this_thread::get_id();
printf("hello boost::asio::post in io_context [%s]\n", oss.str().c_str());
for (int i = 0; i < loop_count; i++)
{
sum += 2;
}
});
}

boost::thread_group tg;
for (int i = 0; i < thread_count; i++)
{
tg.create_thread(thread_func(i+1, ctx));
}
tg.join_all();

printf("[make_sum_to_100_million_without_strand, %d thread] sum : %d\n", thread_count, sum);
}

int main()
{
int thread_count = 2;
make_sum_to_100_million_without_strand(thread_count)
return 0;
}

실행결과

1
[make_sum_to_100_million_without_strand, 2 thread] sum : 50614948

(위 예시의 경우에는 2개의 쓰레드에서)핸들러 동기화를 수행하지 않는 경우에는 1억이 정상적으로 출력되지 않음을 볼 수 있습니다. 여러 쓰레드에서 동시에 접근하는 변수에 대한 명시적인 동기화 처리를 해주지 않았기 때문에 당연한 결과입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <iostream>

#include <boost/asio.hpp>
#include <boost/thread.hpp>
#include <boost/thread/detail/thread_group.hpp>

int sum = 0;

class thread_func
{
public:
thread_func(int idx, boost::asio::io_context& ctx) : idx_(idx), ctx_(ctx)
{

}

void operator()()
{
std::ostringstream oss;
oss << std::this_thread::get_id();
printf("io_context::run #%d thread [%s]\n", idx_, oss.str().c_str());
ctx_.run();
}

private:
int idx_;
boost::asio::io_context& ctx_;
};

void make_sum_to_100_million_with_strand(int thread_count)
{
boost::asio::io_context ctx;
boost::asio::io_context::strand strand(ctx);

int loop_count = 0;
switch (thread_count)
{
case 1:
loop_count = 50000000;
break;
case 2:
loop_count = 25000000;
break;
case 4:
loop_count = 12500000;
break;
case 8:
loop_count = 6250000;
break;
case 16:
loop_count = 3125000;
break;
default:
break;
}

// strand
for (int i = 0; i < thread_count; i++)
{
boost::asio::post(strand.wrap([&](){
std::ostringstream oss;
oss << std::this_thread::get_id();
printf("hello boost::asio::post in io_context [%s]\n", oss.str().c_str());
for (int i = 0; i < loop_count; i++)
{
sum += 2;
}
}));
}

boost::thread_group tg;
for (int i = 0; i < thread_count; i++)
{
tg.create_thread(thread_func(i+1, ctx));
}
tg.join_all();

printf("[make_sum_to_100_million_with_strand, %d thread] sum : %d\n", thread_count, sum);
}

int main()
{
int thread_count = 16;
make_sum_to_100_million_with_strand(thread_count);
return 0;
}

실행결과

1
[make_sum_to_100_million_with_strand, 16 thread] sum : 100000000

(위 예시의 경우에는 16개의 쓰레드에서) boost strand를 사용하여 핸들러 동기화를 수행(전역 변수인 sum에 접근하는 핸들러가 동시에 실행되지 않음을 보장)하였기때문에 1억이 정상적으로 출력됨을 확인할 수 있습니다.


마치며

  • I/O thread가 1개인 경우(즉, io_context::run() 함수가 호출되는 쓰레드가 1개인 경우)에는 오직 하나의 쓰레드에서만 핸들러가 호출되므로 동기화라는게 필요없음.
  • 2개 이상의 I/O thread(즉, io_context::run() 함수가 호출되는 쓰레드가 2개 이상인 경우)를 사용하는 경우에는 핸들러를 동기화하기 위해 명시적인 Lock이 필요함.
  • 이때 사용할 수 있는게 boost::asio::strand! 핸들러 내부에서 동기화를 위한 작업을 따로 해줄필요가 없다!


해당 게시글에서 발생한 오탈자나 잘못된 내용에 대한 정정 댓글 격하게 환영합니다😎

Reference