반응형

한 프로세스(또는 쓰레드) 내에서의 프로그램의 실행 순서는 한 프로세스(한 쓰레드) 내에선 동일하지만, 프로세스들간의 사이서 프로그램 실행 순서는 정의 되지 않는다는 것

 

Sequential consistency is one of the consistency models used in the domain of concurrent computing (e.g. in distributed shared memory, distributed transactions, etc.).

It was first defined as the property that requires that

"... the result of any execution is the same as if the operations of all the processors were executed in some sequential order, and the operations of each individual processor appear in this sequence in the order specified by its program."[1]

To understand this statement, it is essential to understand one key property of sequential consistency: execution order of program in the same processor (or thread) is the same as the program order, while execution order of program between processors (or threads) is undefined. In an example like this:

Execution order between A1, B1 and C1 is preserved, that is, A1 runs before B1, and B1 before C1. The same for A2 and B2. But, as execution order between processors is undefined, B2 might run before or after C1 (B2 might physically run before C1, but the effect of B2 might be seen after that of C1, which is the same as "B2 run after C1")

Conceptually, there is single global memory and a "switch" that connects an arbitrary processor to memory at any time step. Each processor issues memory operations in program order and the switch provides the global serialization among all memory operations[2]

The sequential consistency is weaker than strict consistency, which requires a read from a location to return the value of the last write to that location; strict consistency demands that operations be seen in the order in which they were actually issued.

 

 

 

ref : https://en.wikipedia.org/wiki/Sequential_consistency

반응형
반응형

Guarantees of the C++ runtime

I already presented the details to the thread-safe initialization of variables in the post Thread-safe initialization of data.

Meyers Singleton

The beauty of the Meyers Singleton in C++11 is that it's automatically thread-safe. That is guaranteed by the standard: Static variables with block scope. The Meyers Singleton is a static variable with block scope, so we are done. It's still left to rewrite the program for four threads.

 

// singletonMeyers.cpp

#include <chrono>
#include <iostream>
#include <future>

constexpr auto tenMill= 10000000;

class MySingleton{
public:
  static MySingleton& getInstance(){
    static MySingleton instance;
    // volatile int dummy{};
    return instance;
  }
private:
  MySingleton()= default;
  ~MySingleton()= default;
  MySingleton(const MySingleton&)= delete;
  MySingleton& operator=(const MySingleton&)= delete;

};

std::chrono::duration<double> getTime(){

  auto begin= std::chrono::system_clock::now();
  for ( size_t i= 0; i <= tenMill; ++i){
      MySingleton::getInstance();
  }
  return std::chrono::system_clock::now() - begin;
  
};

int main(){
 
    auto fut1= std::async(std::launch::async,getTime);
    auto fut2= std::async(std::launch::async,getTime);
    auto fut3= std::async(std::launch::async,getTime);
    auto fut4= std::async(std::launch::async,getTime);
    
    auto total= fut1.get() + fut2.get() + fut3.get() + fut4.get();
    
    std::cout << total.count() << std::endl;

}

 

 

I use the singleton object in the function getTime (line 24 - 32). The function is executed by the four promise in line 36 - 39. The results of the associate futures are summed up in line 41. That's all. Only the execution time is missing.

Without optimization

 

Maximum optimization

빠르다 Thread safe

 

The next step is the function std::call_once in combination with the flag std::once_flag.

 

 

 

 

The function std::call_once and the flag std::once_flag

You can use the function std::call_once to register a callable which will be executed exactly once. The flag std::call_once in the following implementation guarantees that the singleton will be thread-safe initialized.

 

// singletonCallOnce.cpp

#include <chrono>
#include <iostream>
#include <future>
#include <mutex>
#include <thread>

constexpr auto tenMill= 10000000;

class MySingleton{
public:
  static MySingleton& getInstance(){
    std::call_once(initInstanceFlag, &MySingleton::initSingleton);
    // volatile int dummy{};
    return *instance;
  }
private:
  MySingleton()= default;
  ~MySingleton()= default;
  MySingleton(const MySingleton&)= delete;
  MySingleton& operator=(const MySingleton&)= delete;

  static MySingleton* instance;
  static std::once_flag initInstanceFlag;

  static void initSingleton(){
    instance= new MySingleton;
  }
};

MySingleton* MySingleton::instance= nullptr;
std::once_flag MySingleton::initInstanceFlag;

std::chrono::duration<double> getTime(){

  auto begin= std::chrono::system_clock::now();
  for ( size_t i= 0; i <= tenMill; ++i){
      MySingleton::getInstance();
  }
  return std::chrono::system_clock::now() - begin;
  
};

int main(){

    auto fut1= std::async(std::launch::async,getTime);
    auto fut2= std::async(std::launch::async,getTime);
    auto fut3= std::async(std::launch::async,getTime);
    auto fut4= std::async(std::launch::async,getTime);
    
    auto total= fut1.get() + fut2.get() + fut3.get() + fut4.get();
    
    std::cout << total.count() << std::endl;

}

 

Here are the numbers.

Without optimization

Maximum optimization

Of course, the most obvious way is it protects the singleton with a lock.

 

 

 

 

 

 

Lock

The mutex wrapped in a lock guarantees that the singleton will be thread-safe initialized.

 

// singletonLock.cpp

#include <chrono>
#include <iostream>
#include <future>
#include <mutex>

constexpr auto tenMill= 10000000;

std::mutex myMutex;

class MySingleton{
public:
  static MySingleton& getInstance(){
    std::lock_guard<std::mutex> myLock(myMutex);
    if ( !instance ){
        instance= new MySingleton();
    }
    // volatile int dummy{};
    return *instance;
  }
private:
  MySingleton()= default;
  ~MySingleton()= default;
  MySingleton(const MySingleton&)= delete;
  MySingleton& operator=(const MySingleton&)= delete;

  static MySingleton* instance;
};


MySingleton* MySingleton::instance= nullptr;

std::chrono::duration<double> getTime(){

  auto begin= std::chrono::system_clock::now();
  for ( size_t i= 0; i <= tenMill; ++i){
       MySingleton::getInstance();
  }
  return std::chrono::system_clock::now() - begin;
  
};

int main(){
  
    auto fut1= std::async(std::launch::async,getTime);
    auto fut2= std::async(std::launch::async,getTime);
    auto fut3= std::async(std::launch::async,getTime);
    auto fut4= std::async(std::launch::async,getTime);
    
    auto total= fut1.get() + fut2.get() + fut3.get() + fut4.get();
    
    std::cout << total.count() << std::endl;
}

 

How fast is the classical thread-safe implementation of the singleton pattern?

Without optimization

 

Maximum optimization

뮤텍스만 적용하면 이때는 Meyers Singleton 에 비해 많이 느리 다는 것을 알 수 있다

아마 그냥 mutex 만 걸면 동기화는 되지만 다른 연산들이 한번에 처리 되는

대체적으로 빠른 원자적 연산이 없기 때문에 몇번 더 연산을 해야 함 => 최적화가 덜 됨

 

Not so fast. Atomics should make the difference.

 

 

 

하지만 Atomic 을 적용하면 Meyers Singleton 만큼 빨라진다

 

Atomic variables

With atomic variables, my job becomes extremely challenging. Now I have to use the C++ memory model. I base my implementation on the well-known  double-checked locking pattern.

 double-checked locking pattern. : (간략하게 말하면 if 문 으로 두번 instnace 를 두번 체크 하는 것)

 

 

참고 : 더블체크드 락 패턴 (https://3dmpengines.tistory.com/2201)

 

Singleton multithreading programs (1)

Static variables with block scope Static variables with block scope will be created exactly once. This characteristic is the base of the so called Meyers Singleton, named after Scott Meyers.  Thi..

3dmpengines.tistory.com

 

Sequential consistency

The handle to the singleton is atomic. Because I didn't specify the C++ memory model the default applies: Sequential consistency.

 

 

// singletonAcquireRelease.cpp

#include <atomic>
#include <iostream>
#include <future>
#include <mutex>
#include <thread>

constexpr auto tenMill= 10000000;

class MySingleton{
public:
  static MySingleton* getInstance(){
    MySingleton* sin= instance.load();
    if ( !sin ){
      std::lock_guard<std::mutex> myLock(myMutex);
      sin= instance.load();
      if( !sin ){
        sin= new MySingleton();
        instance.store(sin);
      }
    }   
    // volatile int dummy{};
    return sin;
  }
private:
  MySingleton()= default;
  ~MySingleton()= default;
  MySingleton(const MySingleton&)= delete;
  MySingleton& operator=(const MySingleton&)= delete;

  static std::atomic<MySingleton*> instance;
  static std::mutex myMutex;
};


std::atomic<MySingleton*> MySingleton::instance;
std::mutex MySingleton::myMutex;

std::chrono::duration<double> getTime(){

  auto begin= std::chrono::system_clock::now();
  for ( size_t i= 0; i <= tenMill; ++i){
       MySingleton::getInstance();
  }
  return std::chrono::system_clock::now() - begin;
  
};


int main(){

    auto fut1= std::async(std::launch::async,getTime);
    auto fut2= std::async(std::launch::async,getTime);
    auto fut3= std::async(std::launch::async,getTime);
    auto fut4= std::async(std::launch::async,getTime);
    
    auto total= fut1.get() + fut2.get() + fut3.get() + fut4.get();
    
    std::cout << total.count() << std::endl;

}

 

Now I'm curious.

Without optimization

 

Maximum optimization

 

But we can do better. There is an additional optimization possibility.

 

atomic 만 추가 했는데  Meyers Singleton  만큼 빨라진 것을 볼 수 있다

 

 

여기서 약간 더 최적화 할 수 있는데 Acquire-release 체계를 사용 하는것 Memory Model

 

Acquire-release Semantic

The reading of the singleton (line 14) is an acquire operation, the writing a release operation (line 20). Because both operations take place on the same atomic I don't need sequential consistency. The C++ standard guarantees that an acquire operation synchronizes with a release operation on the same atomic. These conditions hold in this case therefore I can weaken the C++ memory model in line 14 and 20. Acquire-release semantic is sufficient.

 

// singletonAcquireRelease.cpp

#include <atomic>
#include <iostream>
#include <future>
#include <mutex>
#include <thread>

constexpr auto tenMill= 10000000;

class MySingleton{
public:
  static MySingleton* getInstance(){
    MySingleton* sin= instance.load(std::memory_order_acquire);
    if ( !sin ){
      std::lock_guard<std::mutex> myLock(myMutex);
      sin= instance.load(std::memory_order_relaxed);
      if( !sin ){
        sin= new MySingleton();
        instance.store(sin,std::memory_order_release);
      }
    }   
    // volatile int dummy{};
    return sin;
  }
private:
  MySingleton()= default;
  ~MySingleton()= default;
  MySingleton(const MySingleton&)= delete;
  MySingleton& operator=(const MySingleton&)= delete;

  static std::atomic<MySingleton*> instance;
  static std::mutex myMutex;
};


std::atomic<MySingleton*> MySingleton::instance;
std::mutex MySingleton::myMutex;

std::chrono::duration<double> getTime(){

  auto begin= std::chrono::system_clock::now();
  for ( size_t i= 0; i <= tenMill; ++i){
       MySingleton::getInstance();
  }
  return std::chrono::system_clock::now() - begin;
  
};


int main(){

    auto fut1= std::async(std::launch::async,getTime);
    auto fut2= std::async(std::launch::async,getTime);
    auto fut3= std::async(std::launch::async,getTime);
    auto fut4= std::async(std::launch::async,getTime);
    
    auto total= fut1.get() + fut2.get() + fut3.get() + fut4.get();
    
    std::cout << total.count() << std::endl;

}

 

The acquire-release semantic has a similar performance as the sequential consistency. That's not surprising, because on x86 both memory models are very similar. We would get totally different numbers on an ARMv7 or PowerPC architecture. You can read the details on Jeff Preshings blog Preshing on Programming.

Without optimization

 

Maximum optimization

.

If I forget an import variant of the thread-safe singleton pattern, please let me know and send me the code. I will measure it and add the numbers to the comparison.

 

미소하지만 약간더 빨라 진 것을 볼 수 있다

 

Momory Model 중

acquire-release 방식의 의미는 순차적 일관성(https://3dmpengines.tistory.com/2205)과 유사하다

 

그리고 acquire과 release 는 다음과 같은 의미를 갖고 있는데

  1.  // Load-Acquire 수행
  2.     int ready = g_guard.load(memory_order_acquire);
  3.     // memory_order_acquire 이후부터는 Release 이전에 쓰여진 메모리 값들이 모두 제대로 보인다

 

  1.    // 여기에서 Write-Release 수행
  2.     // memory_order_release 키워드 이후 부터는 지금까지 쓴 내용들이 Acquire 이후에 보여진다.
  3.     g_guard.store(1, memory_order_release);

 

명령어가 순서적으로 실행된다는 의미 정도로 생각 하면 될것으로 보인다

 

acqurie-release 방식으로 사용하면 이 중간에 있는 코드는 메모리 재배치를 하지 않겠다는 의미

 

fence 라고 해서 앞뒤로 장벽을 처서 명령어 재배치 되지 않게 할수 있는데 (acquire-release 방식으로..)

하지만 결과는 거의동일하다

참고 : fence 을 활용한 Singleton (https://3dmpengines.tistory.com/2200)

 

 

ref :http://www.modernescpp.com/index.php/component/jaggyblog/thread-safe-initialization-of-a-singleton

ref : http://egloos.zum.com/sweeper/v/3059861

반응형
반응형

Static variables with block scope

Static variables with block scope will be created exactly once. This characteristic is the base of the so called Meyers Singleton, named after Scott Meyers.  This is by far the most elegant implementation of the singleton pattern.

#include <thread>

class MySingleton{
public:
  static MySingleton& getInstance(){
    static MySingleton instance;
    return instance;
  }
private:
  MySingleton();
  ~MySingleton();
  MySingleton(const MySingleton&)= delete;
  MySingleton& operator=(const MySingleton&)= delete;

};

MySingleton::MySingleton()= default;
MySingleton::~MySingleton()= default;

int main(){

  MySingleton::getInstance();

}

 

By using the keyword default, you can request special methods from the compiler. They are Special because only compiler can create them. With delete, the result is, that the automatically generated methods (constructor, for example) from the compiler will not be created and, therefore, can not be called. If you try to use them you'll get an compile time error. What's the point of the Meyers Singleton in multithreading programs? The Meyers Singleton is thread safe.

A side note: Double-checked locking pattern

Wrong beliefe exists, that an additional way for the thread safe initialization of a singleton in a multithreading environment is the double-checked locking pattern. The double-checked locking pattern is - in general -  an unsafe way to initialize a singleton. It assumes guarantees in the classical implementation, which aren't given by the Java, C# or C++ memory model. The assumption is, that the access of the singleton is atomic.

But, what is the double-checked locking pattern? The first idea to implement the singleton pattern in a thread safe way, is  to protected the initialization of the singleton by a lock.

mutex myMutex;

class MySingleton{
public:  
  static MySingleton& getInstance(){    
    lock_guard<mutex> myLock(myMutex);      
    if( !instance ) instance= new MySingleton();    
    return *instance;  
  }
private:  
  MySingleton();  
  ~MySingleton();  
  MySingleton(const MySingleton&)= delete;  
  MySingleton& operator=(const MySingleton&)= delete;
  static MySingleton* instance;
};
MySingleton::MySingleton()= default;
MySingleton::~MySingleton()= default;
MySingleton* MySingleton::instance= nullptr;
 

 

Any issues? Yes and no. The implementation is thread safe. But there is a great performance penalty. Each access of the singleton in line 6 is protected by an expansive lock. That applies also for the reading access. Most time it's not necessary. Here comes the double-checked locking pattern to our rescue.

 

static MySingleton& getInstance(){    
  if ( !instance ){      
    lock_guard<mutex> myLock(myMutex);      
    if( !instance ) instance= new MySingleton();    
  }
  return *instance; 
}

 

 

I use inexpensive pointer comparison  in the line 2 instead of an expensive lock a. Only if I get a null pointer, I apply the expensive lock on the singleton (line 3). Because there is the possibility that another thread will initialize the singleton between the pointer comparison (line 2) and the lock (line3), I have to perform an additional pointer comparison the on line 4. So the name is clear. Two times a check and one time a lock.

Smart? Yes. Thread safe? No.

What is the problem? The call instance= new MySingleton() in line 4 consists of at least three steps.

  1. Allocate memory for MySingleton
  2. Create the MySingleton object in the memory
  3. Let instance refer to the MySingleton object


The problem: there is no guarantee about  the sequence of these steps. For example, out of optimization reasons, the processor can reorder the steps to the sequence 1,3 and 2. So, in the first step the memory will be allocated and in the second step, instance refers to an incomplete singleton. If at that time another thread tries to access the singleton, it compares the pointer and gets the answer true. So, the other thread has the illusion that it's dealing with a complete singleton.

The consequence is simple: program behaviour is undefined.

 

최적화에 의해서 명령어 순서가 1,3 ,2 순서로 뒤 바뀔 수 있기 때문에 다른 스레드에서 잘못된 instance 포인터를 얻어가게 되어 문제가 발생 할 수 있게 된다

핵심은 Mutex 가  reorder 를 막아주진 못한다는 것!!!

 

 

What's next?

At first, I thought, I should continue in the next post with the singleton pattern. But to write about the singleton pattern, you should have a basic knowledge of the memory model. So I continue in the sequence of my German blog. The next post will be about-thread local storage. In case we are done with the high end API of multithreading in C++, I'll go further with the low end API. (Proofreader Alexey Elymanov)

 

 

ref : https://www.modernescpp.com/index.php/thread-safe-initialization-of-data

 

반응형
반응형

 

memory_order_release, memory_order_acquire 는  아래 글을 참고 하면 되겠다

(atomic 예시 와 Memory Model (정책) , memory_order_...) https://3dmpengines.tistory.com/2199

 

atomic 예시 와 Memory Model (정책) , memory_order_...

atomic flag = false; //true 가 나오면 원래 원자적으로 처리 되는거라 lock 할 필요 없음 bool isAtomic = flag.is_lock_free(); //flag.store(true); flag.store(true, memory_order::memory_order_seq_cst); /..

3dmpengines.tistory.com

 

 

 

 

C++11은 두 개의 memory fence(memory barrier) 기능을 제공한다.

 

fence는 non-atomic operation 또는 memory_order_relaxed로 수행되는
atomic operation 메모리 배리어 역할을 수행한다.

 

 

1) atomic_thread_fence(memory_order order)

메모리 가시성 강제메모리 재배치금지한다.
사실 fence는 atomic 클래스나 atomic instrinsic 함수들을 사용한다면, 굳이 사용할 필요가 없다고 생각한다.
 
굳이 이를 사용하려면, atomic 멤버 함수 호출시 memory_order_relaxed(메모리 배치에 관여하지 않음) 방식을 쓴 뒤,
인위적으로 fence를 호출해 주는 방식을 써야 하는데 굳이 쓸 일이 있을까 싶다.
 
앞서 소개했던 Write-Release / Read-Acquire 예제를 atomic_thread_fence 버전으로 바꿔보자.
 
  1. // 쓰레드 1에서의 producer
  2. void TrySendMessage()
  3. {
  4.     //...
  5.    
  6.     g_payload.tick = clock();
  7.     g_payload.str = "TestMessage";
  8.     g_payload.param = param;
  9.  
  10.     // 지금까지 쓴 내용들이 Acquire를 수행한 쓰레드에서 보여져야 한다.
  11.     atomic_thread_fence(memory_order_release);
  12.  
  13.     g_guard.store(1, memory_order_relaxed);
  14. }
  15.  
  16. // 쓰레드 2에서 대기중인 consumer
  17. void TryReceiveMessage()
  18. {
  19.     // ...
  20.  
  21.     // Load 수행
  22.     int ready = g_guard.load(memory_order_relaxed);
  23.     if (ready != 0)
  24.     {
  25.         atomic_thread_fence(memory_order_acquire);
  26.         // 이후부터는 Release 이전에 쓰여진 메모리 값들이 모두 제대로 보여야 한다
  27.  
  28.         result.tick = g_payload.tick;
  29.         result.str = g_payload.str;
  30.         result.param = g_payload.param;
  31.     }
  32. }
 
위 코드의 흐름을 그림으로 표현하면 아래와 같다.
 
 
또 하나의 예를 들자면, 싱글턴에서 자주 사용되는 Double-Checked Locking 기법일 것이다.
 
  1. using namespace std;
  2.  
  3. atomic<Singleton*> Singleton::m_instance;
  4. mutex Singleton::m_mutex;
  5.  
  6. // Double-Checked Locking 기법에 relaxed order와 memory fence 활용
  7. Singleton* Singleton::getInstance()
  8. {
  9.     Singleton* tmp = m_instance.load(memory_order_relaxed);
  10.     atomic_thread_fence(memory_order_acquire)
  11.     if (tmp == nullptr)
  12.     {
  13.         lock_guard<mutex> lock(m_mutex);
  14.  
  15.         tmp = m_instance.load(memory_order_relaxed);
  16.         if (tmp == nullptr)
  17.         {
  18.             tmp = new Singleton;
  19.  
  20.             atomic_thread_fence(memory_order_release);
  21.             m_instance.store(tmp, memory_order_relaxed);
  22.         }
  23.     }
  24.     return tmp;
  25. }
 
 
 
 
 
 
위 코드의 흐름은 아래 그림과 같다.
 
헌데, 아무리 sequential consistency가 위 방식보다 조금 덜 효율적이라 하지만, 
실제 퍼포먼스 테스트를 해 본 결과 의미를 부여할 만한 성능 차이를 전혀 발견하지 못했다.
 
아래 예제는 C++ atomic default인 memory_order_seq_cst 버전이다. 깔끔!!!
(Intel TBB로 구현했다면, default가 release-acquire)
 
  1. // C++11 디폴트 sequential consistency 버전
  2. Singleton* Singleton::getInstance()
  3. {
  4.     Singleton* inst = m_instance.load();
  5.     if (inst == nullptr)
  6.     {
  7.         lock_guard<std::mutex> lock(m_mutex);
  8.  
  9.         inst = m_instance.load(/*memory_order_seq_cst*/);
  10.         if (inst == nullptr)
  11.         {
  12.             inst = new Singleton;
  13.             m_instance.store(inst/*, memory_order_seq_cst*/);
  14.         }
  15.     }
  16.     return inst;
  17. }
 
 

2) atomic_signal_fence(memory_order order)

 
메모리 재배치를 금지한다.
 
4. 추가 링크
 
아래 링크들이 워낙 좋아 추가로 링크를 걸어둔다.
아티클 안에 링크된 페이지들도 읽어 볼 만한 내용이 많으므로, 시간날 때마다 탐독할 것.
(사실 위 내용들의 예제/그림들 중 아래 링크들에서 퍼온 게 많다)
 
반응형
반응형
	atomic<bool> flag = false;

	//true 가 나오면 원래 원자적으로 처리 되는거라 lock 할 필요 없음
	bool isAtomic = flag.is_lock_free();
	

	//flag.store(true);
	flag.store(true, memory_order::memory_order_seq_cst);	//위의 한줄과 동일한 연산이다

	bool val = flag.load(memory_order::memory_order_seq_cst);

기본 적인 storeload 이전에 사용했던 것에서 뒤에 인자를 넣어 줄수 있다는 것

 

 

 

 

#include "pch.h"
#include <iostream>
#include <thread>	
#include <mutex>
#include <chrono>
#include <future>
#include "windows.h"
using namespace std;

atomic<bool> flag = false;

int main()
{
	flag = false;

	flag.store(true, memory_order::memory_order_seq_cst);	//memory_order_seq_cst 가 위에선 기본으로 들어간다

	bool val = flag.load(memory_order::memory_order_seq_cst);

	{
		bool prev = flag;		//이렇게 하면 만약 다른 쓰레드에서 flag 값을 중간에 바꾸면 prev 는 falg 과 동일한 값이 아닐 수도 있게된다
		flag = true;
	}


	//위의 것을 원자적으로 한번에 변경 하려면 다음 처럼 하면 된다 
	{
		//이렇게 한번에 처리하는 atomic 의 함수를 사용 하면 된다
		bool prev = flag.exchange(true);
	}

	//cas (compare-and-swap) 조건부 수정
	{
		bool expected = false;
		bool desired = true;
		flag.compare_exchange_strong(expected, desired);		//현재 값이 기대한 값과 같다면 희망하는 값으로 바꿔주는 함수

	}

	{
		bool expected = false;
		bool desired = true;
		flag.compare_exchange_weak(expected, desired);		//동작은 compare_exchange_strong 과 같은데 spruious failure 가 발생 할 수 있다
		//즉 이 함수 내에서 다른 스레드의 Interruption 을 받아서 값 변경이 중간에 실패 할 수 있음
		//lock 해야 하는데 다른곳에서 먼저 lock 해서 가짜 wakup 이 일어나 제대로 처리 되지 않는 묘한 상황이 발생 할 수 있다는 것

		//compare_exchange_weak 이게 기본적인 형태인데 만약에 이렇게 실패가 일어나면 다시 한번 시도해서 성공할대까지 시도하는게 compare_exchange_strong 이 된다 
		//compare_exchange_strong 그래서 이게 좀 더 부하가 있을 수 있다

		//그래서 compare_exchange_weak 이걸 사용할대는 while 루프를 사용해서 처리 한다, 하지만 성능 차이가 엄청 크게 차이 나진 않는다
	}
	return 0;
}

compare_exchange_weak 같은 경우

compare_exchange_weak  함수 내에서  if(flag==expected){ 여기 안에서 실패 발생 가능성  }  이런 내부적 유사구문에서 실패를 할수 있기 때문에 가짜기상이 일어날 수 있다 compare_exchange_strong 같은경우는 이 실패가 일어날 경우 성공 할떄까지 반복해서 실행하게 된다

그래서 compare_exchange_weak  은  while 루프와 함께 사용 되어서 성공할때까지 시도 하는 형태로 코드를 작성하게 된다

 

 

 

 

Memory Model (정책) 에는 다음과 같은 것들이 있다

 

load, store 의 뒤에 넣는 인자들이 다음 처럼 있다는 얘기

  1. Sequentianlly Consistent (seq_cst)
  2. Acquire-Release (consume, acquire, release, acq_rel)
  3. Relaxed (relaxed)

 

consume 은 뭔가 문제가 있음

 

 

 

여기서 중요한것은 seq_cst, acquire, release, acq_rel 이렇게 4개 이다

이 중 acq_rel  이것은 acquire, release 을 합처 놓은 것이다

 

정리하면..

  1. Sequentianlly Consistent (memory_order_seq_cst) : 가장 엄격
    [컴파일러에서 최적화 여지 적음=>코드 직관적] =>코드 재배치 잘 안됨 : 가시성 문제와 코드 재배치 문제가 해결 된다

  2. Acquire-Release (memory_order_acquire, memory_order_release) : 중간 엄격
    ex) ready.store(true, memory_order_release);
    release 명령 이전의 메모리 명령들이, 해당 명령 이후로 재배치 되는 것을 막는다 => 즉 재배치 억제
    ready.store(true, memory_order_acquire); //  acquire 로 같은 변수를 읽는 쓰레드가 있다면
    release 이전의 명령들이 ->acqurie 하는 순간에 관찰 가능하다(가시성 보장) 
    즉 memory_order_release와 memory_order_relase 이전의 내용들이
    memory_order_acquire 한 이후의 가시성이 보장이 된다는 얘기,
    다시 말해  memory_order_release 이전에 오는 모든 메모리 명령들이 memory_order_acquire  한
    이후의
    해당 쓰레드에 의해서 
    관찰 될 수 있어야 합니다.

  3. Relaxed (memory_order_relaxed)  : 사용할 일이 거의 없다
    자유로워짐 [컴파일러 최적화 여지가 많다 => 코드가 복잡] =>
    코드 재배치 잘 될 수 있음, 가시성도 해결 되지 못한다
    단 동일 객체에 대한 동일 수정 순서만 보장한다
    참고 (https://3dmpengines.tistory.com/2198?category=511463)

 

atomic 은 아무것도 입력하지 않으면 seq_cst 이 기본 버전으로 처리 된다 => 가장 많이 사용 됨

 

 

 

 


 

 

[1] atomic 중에서 memory_order::memory_order_seq_cst

#include "pch.h"
#include <iostream>
#include <thread>	
#include <mutex>
#include <chrono>
#include <future>
#include "windows.h"
using namespace std;

atomic<bool> ready = false;
int32 value;

void producer()
{
	value = 10;
    
    //seq_cst를 하게 되면 ready 이 값이 consumer 에서의 ready 에서도 그대로 보이고 재배치문제도 해결됨
    // cst 를 하게 되면 value 값이 cst 이후에 정확하게 갱신되어 값이 이후에 보여지게 된다
    //releaxed 같은 경우는 재배치로 인해 0이 나올 수도 있다, 대부분 테스트하면 10이 나오긴 하지만..
	ready.store(true, memory_order::memory_order_seq_cst);
}

void consumer()
{
	while (ready.load(memory_order::memory_order_seq_cst) == false)
		;
	cout << value << endl;
}

int main()
{
	ready = false;
	value = 0;
	thread t1(producer);
	thread t2(consumer);

	t1.join();
	t2.join();

	/*
	memory_order_seq_cst 이걸 쓰면 가시성 문제와 코드 재배치 문제가 해결 된다!!!
	모든게 정상적으로 작동
	*/

	return 0;
}

ready.store(true, memory_order::memory_order_seq_cst); 이 한줄에 대해서만

 

 

memory_order_seq_cst

memory_order_seq_cst 는 메모리 명령의 순차적 일관성(sequential consistency) 을 보장해줍니다.

순차적 일관성이란, 메모리 명령 재배치도 없고, 모든 쓰레드에서 모든 시점에 동일한 값을 관찰할 수 있는, 여러분이 생각하는 그대로 CPU 가 작동하는 방식이라 생각하면 됩니다.

memory_order_seq_cst 를 사용하는 메모리 명령들 사이에선 이러한 순차적 일관성을 보장해줍니다.

 

 

 


 

 

[3] atomic 중에서 memory_order_relaxed

#include "pch.h"
#include <iostream>
#include <thread>	
#include <mutex>
#include <chrono>
#include <future>
#include "windows.h"
using namespace std;

atomic<bool> ready = false;
int32 value;

void producer()
{
	value = 10;
	ready.store(true, memory_order::memory_order_relaxed);
}

void consumer()
{
	while (ready.load(memory_order::memory_order_relaxed) == false)
		;
	cout << value << endl;
}

int main()
{
	ready = false;
	value = 0;
	thread t1(producer);
	thread t2(consumer);

	t1.join();
	t2.join();
    
	return 0;
}

memory_order_relaxed : 가장 루즈한 규칙으로 가시성과 코드 재배치가 해결되지 않는다
사실상 거의 쓰이지 않는다

즉 
value = 10;
ready.store(true, memory_order::memory_order_relaxed);

이 코드가

ready.store(true, memory_order::memory_order_relaxed);
value = 10;

이렇게 컴파일 될 수도 있단 얘기임

 

 

 

또 다른 얘시

#include <atomic>
#include <cstdio>
#include <thread>
#include <vector>
using std::memory_order_relaxed;

void t1(std::atomic<int>* a, std::atomic<int>* b) {
  b->store(1, memory_order_relaxed);      // b = 1 (쓰기)
  int x = a->load(memory_order_relaxed);  // x = a (읽기)

  printf("x : %d \n", x);
}

void t2(std::atomic<int>* a, std::atomic<int>* b) {
  a->store(1, memory_order_relaxed);      // a = 1 (쓰기)
  int y = b->load(memory_order_relaxed);  // y = b (읽기)

  printf("y : %d \n", y);
}

int main() {
  std::vector<std::thread> threads;

  std::atomic<int> a(0);
  std::atomic<int> b(0);

  threads.push_back(std::thread(t1, &a, &b));
  threads.push_back(std::thread(t2, &a, &b));

  for (int i = 0; i < 2; i++) {
    threads[i].join();
  }
}

성공적으로 컴파일 하였다면 아래와 같은 결과들을 확인할 수 있습니다.

실행 결과

x : 1 
y : 0 

혹은

실행 결과

x : 0 
y : 1 

혹은

실행 결과

y : 1 
x : 1

을 말이지요.

 

b->store(1, memory_order_relaxed);      // b = 1 (쓰기)
int x = a->load(memory_order_relaxed);  // x = a (읽기)

store  load  atomic 객체들에 대해서 원자적으로 쓰기와 읽기를 지원해주는 함수 입니다. 이 때, 추가적인 인자로, 어떠한 형태로 memory_order 을 지정할 것인지 전달할 수 있는데, 우리의 경우 가장 느슨한 방식인 memory_order_relaxed 를 전달하였습니다.

 

여기서 잠깐 궁금한게 있습니다. 과연 아래와 같은 결과를 볼 수 있을까요?

 

 

실행 결과

x : 0 
y : 0

상식적으로는 불가능 합니다. 왜냐하면 x, y 둘다 0 이 나오기 위해서는 x = a  y = b 시점에서 a  b 모두 0 이어야만 합니다. 하지만 위 명령어들이 순서대로 실행된다면 이는 불가능 하다는 사실을 알 수 있습니다.

예를 들어서 x 에 0 이 들어가려면 a 가 0 이어야 합니다. 이 말은 즉슨, x = a 가 실행된 시점에서 a = 1 이 실행되지 않았어야만 합니다. 따라서 t2 에서 y = b 를 할 때 이미 b 는 1 인 상태이므로, y 는 반드시 1 이 출력되어야 하지요.

하지만, 실제로는 그렇지 않습니다. memory_order_relaxed 는 앞서 말했듯이, 메모리 연산들 사이에서 어떠한 제약조건도 없다고 하였습니다. 다시 말해 서로 다른 변수의 relaxed 메모리 연산은 CPU 마음대로 재배치 할 수 있습니다 (단일 쓰레드 관점에서 결과가 동일하다면).

 

예를 들어서

int x = a->load(memory_order_relaxed);  // x = a (읽기)
b->store(1, memory_order_relaxed);      // b = 1 (쓰기)

순으로 CPU 가 순서를 재배치 하여 실행해도 무방하다는 뜻입니다.

그렇다면 이 경우 x  y 에 모두 0 이 들어가겠네요. memory_order_relaxed 는 CPU 에서 메모리 연산 순서에 관련해서 무한한 자유를 주는 것과 같습니다. 덕분에 CPU 에서 매우 빠른 속도로 실행할 수 있게됩니다.

이렇게 relaxed 메모리 연산을 사용하면 예상치 못한 결과를 나을 수 있지만, 종종 사용할 수 있는 경우가 있습니다.

 

 

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
using std::memory_order_relaxed;

void worker(std::atomic<int>* counter) {
  for (int i = 0; i < 10000; i++) {
    // 다른 연산들 수행

    counter->fetch_add(1, memory_order_relaxed);
  }
}
int main() {
  std::vector<std::thread> threads;

  std::atomic<int> counter(0);

  for (int i = 0; i < 4; i++) {
    threads.push_back(std::thread(worker, &counter));
  }

  for (int i = 0; i < 4; i++) {
    threads[i].join();
  }

  std::cout << "Counter : " << counter << std::endl;
}

 

 

성공적으로 컴파일 하였다면

실행 결과

Counter : 40000

와 같이 나옵니다. 여기서 중요한 부분은

 

counter->fetch_add(1, memory_order_relaxed);

로 이는 counter ++ 와 정확히 하는 일이 동일하지만, counter++ 와는 다르게 메모리 접근 방식을 설정할 수 있습니다. 위 문장 역시 원자적으로 counter 의 값을 읽고  1 을 더하고 다시 그 결과를 씁니다.

 

다만 memory_order_relaxed 를 사용할 수 있었던 이유는, 다른 메모리 연산들 사이에서 굳이 counter 를 증가시키는 작업을 재배치 못하게 막을 필요가 없기 때문입니다. 비록 다른 메모리 연산들 보다 counter ++ 이 늦게 된다고 하더라도 결과적으로 증가 되기만 하면 문제 될게 없기 때문 입니다, 그렇다고 이것을 권하는 것은 아님

 

 

 

 

[2] memory_order_acquire 과 memory_order_release

memory_order_relaxed 가 사용되는 경우가 있다고 하더라도 너무나 CPU 에 많은 자유를 부여하기에 그 사용 용도는 꽤나 제한적입니다. 이번에 살펴볼 것들은 memory_order_relaxed 보다 살짝 더 엄격한 친구들 입니다.

 

relaxed 로 된ㄷ 아래와 같은 producer - consumer 관계를 생각해봅시다.

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
using std::memory_order_relaxed;

void producer(std::atomic<bool>* is_ready, int* data) {
  *data = 10;
  is_ready->store(true, memory_order_relaxed);
}

void consumer(std::atomic<bool>* is_ready, int* data) {
  // data 가 준비될 때 까지 기다린다.
  while (!is_ready->load(memory_order_relaxed)) {
  }

  std::cout << "Data : " << *data << std::endl;
}

int main() {
  std::vector<std::thread> threads;

  std::atomic<bool> is_ready(false);
  int data = 0;

  threads.push_back(std::thread(producer, &is_ready, &data));
  threads.push_back(std::thread(consumer, &is_ready, &data));

  for (int i = 0; i < 2; i++) {
    threads[i].join();
  }
}

 

성공적으로 컴파일 하였다면

실행 결과

Data : 10

일반적인 경우 위와 같이 나옵니다. 하지만, 아래와 같은 결과를 얻을 수 도 있을까요?

실행 결과

Data : 0

있습니다! 왜냐하면 producer 쓰레드를 살펴보자면

 

*data = 10;
is_ready->store(true, memory_order_relaxed);

 is_ready 에 쓰는 연산이 relaxed 이기 때문에 위의 *data = 10 과 순서가 바뀌어서 실행된다면 is_ready  true 임에도 *data = 10 이 채 실행이 끝나지 않을 수 있다는 것이지요.

따라서 consumer 쓰레드에서 is_ready  true 가 되었음에도 제대로된 data 를 읽을 수 없는 상황이 벌어집니다.

 

 

consumer 쓰레드에서도 마찬가지 입니다.

while (!is_ready->load(memory_order_relaxed)) {
}

std::cout << "Data : " << *data << std::endl;

아래에 data 를 읽는 부분과 위 is_ready 에서 읽는 부분이 순서가 바뀌어 버린다면, is_ready  true 가 되기 이전의 data 값을 읽어버릴 수 있다는 문제가 생깁니다. 따라서 위와 같은 생산자 - 소비자 관계에서는 memory_order_relaxed 를 사용할 수 없습니다.

 

 

 

본론으로 돌아와서.. memory_order_release, memory_order_acquire 된 코드

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

void producer(std::atomic<bool>* is_ready, int* data) {
  *data = 10;
  is_ready->store(true, std::memory_order_release);
  //----------------------여기부터 ---------------------------------
  //is_ready->store(true, std::memory_order_release); 이코드 위에 있는 것들이 
  //--------줄 아래로 내려갈 수 없다는 것을 말한다
}

void consumer(std::atomic<bool>* is_ready, int* data) {
  
  // data 가 준비될 때 까지 기다린다.
  //memory_order_acquire 는 같은 변수로 읽는 스레드가 있다면 release 이전의 명령들이
  //acquire 하는 순간에 관찰 가능하다
  //----------------------여기까지 ---------------------------------
  //!is_ready->load(std::memory_order_acquire) 아래 있는 명령들이 이 위로 못올라오게 막고
  //is_ready->store(true, std::memory_order_release); 이전의 내용들이 
  //memory_order_acquire 이후에 모두다 값들이 갱신되어 정확히 보여지게 된다
  while (!is_ready->load(std::memory_order_acquire)) 
  {
  }

  std::cout << "Data : " << *data << std::endl;
}

int main() {
  std::vector<std::thread> threads;

  std::atomic<bool> is_ready(false);
  int data = 0;

  threads.push_back(std::thread(producer, &is_ready, &data));
  threads.push_back(std::thread(consumer, &is_ready, &data));

  for (int i = 0; i < 2; i++) {
    threads[i].join();
  }
}

 

성공적으로 컴파일 하였다면

실행 결과

Data : 10

와 같이 나옵니다. 이 경우 data 에 0 이 들어가는 일은 불가능 합니다. 이유는 아래와 같습니다.

 

*data = 10;
is_ready->store(true, std::memory_order_release);

memory_order_release  해당 명령 이전의 모든 메모리 명령들이 해당 명령 이후로 재배치 되는 것을 금지 합니다. 또한, 만약에 같은 변수를 memory_order_acquire 으로 읽는 쓰레드가 있다면, memory_order_release 이전에 오는 모든 메모리 명령들이 해당 쓰레드에 의해서 관찰 될 수 있어야 합니다.

 

 

쉽게 말해 is_ready->store(true, std::memory_order_release); 밑으로 *data = 10 이 올 수 없게 됩니다.

또한 is_ready  true 가 된다면, memory_order_acquire  is_ready 를 읽는 쓰레드에서 data 의 값을 확인했을 때 10 임을 관찰할 수 있어야하죠.

 

while (!is_ready->load(std::memory_order_acquire)) {
}

 

실제로 cosnumer 쓰레드에서 is_ready  memory_order_acquire  load 하고 있기에, is_ready  true 가 된다면, data 는 반드시 10 이어야만 합니다.

 

memory_order_acquire 의 경우, release 와는 반대로 해당 명령 뒤에 오는 모든 메모리 명령들이 해당 명령 위로 재배치 되는 것을 금지 합니다.

 

이와 같이 두 개의 다른 쓰레드들이 같은 변수의 release  acquire 를 통해서 동기화 (synchronize) 를 수행하는 것을 볼 수 있습니다.

 

 

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
using std::memory_order_relaxed;

std::atomic<bool> is_ready;
std::atomic<int> data[3];

void producer() {
  data[0].store(1, memory_order_relaxed);
  data[1].store(2, memory_order_relaxed);
  data[2].store(3, memory_order_relaxed);
  is_ready.store(true, std::memory_order_release);
}

void consumer() {
  // data 가 준비될 때 까지 기다린다.
  while (!is_ready.load(std::memory_order_acquire)) {
  }

  std::cout << "data[0] : " << data[0].load(memory_order_relaxed) << std::endl;
  std::cout << "data[1] : " << data[1].load(memory_order_relaxed) << std::endl;
  std::cout << "data[2] : " << data[2].load(memory_order_relaxed) << std::endl;
}

int main() {
  std::vector<std::thread> threads;

  threads.push_back(std::thread(producer));
  threads.push_back(std::thread(consumer));

  for (int i = 0; i < 2; i++) {
    threads[i].join();
  }
}

성공적으로 컴파일 하였다면

실행 결과

data[0] : 1
data[1] : 2
data[2] : 3

 

 

data[0].store(1, memory_order_relaxed);
data[1].store(2, memory_order_relaxed);
data[2].store(3, memory_order_relaxed);
is_ready.store(true, std::memory_order_release);

여기서 data 의 원소들을 store 하는 명령들은 모두 relaxed 때문에 자기들 끼리는 CPU 에서 마음대로 재배치될 수 있겠지만, 아래 release 명령을 넘어가서 재배치될 수 는 없습니다.

release - acquire 동기화

따라서 consumer 에서 data 들의 값을 확인했을 때 언제나 정확히 1, 2, 3 이 들어있게 됩니다.

 

 

 

 


[4] memory_order_acq_rel

memory_order_acq_rel 은 이름에서도 알 수 있듯이, acquire  release 를 모두 수행하는 것입니다. 이는, 읽기와 쓰기를 모두 수행하는 명령들, 예를 들어서 fetch_add 와 같은 함수에서 사용될 수 있습니다.

 

 

 


 

 

 

 

Sequentially-consistent Ordering

memory_order_seq_cst 플래그로 설정된 아토믹 객체는 memory_order_seq_cst와 마찬가지로 store 이전의 상황이 다른 스레드의 load시 동기화가 되는 기능을 제공한다. 또한 memory_order_seq_cst는 "single total modification order" 단일 수정 순서 = 전지적 시점에서 실행된 순서가 모든 아토믹 객체를 사용하는 쓰레드 사이에 동기화된다.

 

 

#include <atomic>
#include <iostream>
#include <thread>

std::atomic<bool> x(false);
std::atomic<bool> y(false);
std::atomic<int> z(0);

void write_x() { x.store(true, std::memory_order_release); }

void write_y() { y.store(true, std::memory_order_release); }

void read_x_then_y() {
  while (!x.load(std::memory_order_acquire)) {
  }
  if (y.load(std::memory_order_acquire)) {
    ++z;
  }
}

void read_y_then_x() {
  while (!y.load(std::memory_order_acquire)) {
  }
  if (x.load(std::memory_order_acquire)) {
    ++z;
  }
}

int main() {
  thread a(write_x);
  thread b(write_y);
  thread c(read_x_then_y);
  thread d(read_y_then_x);
  a.join();
  b.join();
  c.join();
  d.join();
  std::cout << "z : " << z << std::endl;
}

 

성공적으로 컴파일 하였다면

실행 결과

z : 2

혹은

실행 결과

z : 1

과 같이 나옵니다. 그렇다면

실행 결과

z : 0

은 발생할 수 있을까요?

 

 

 

기존 release-acquire 관계를 이용하는 a, b, c, d의 스레드를 실행했을 때 보장하는 것은 다음과 같다.

  • write_x()에서 x(store) 이전의 문맥 = read_x_then_y()의 x(load) 시점의 문맥
  • write_y()에서 y(store) 이전의 문맥 = read_y_then_x()의 y(load) 시점의 문맥

따라서 실행 결과 값은 다음과 같이 나올 수 있다.

Z 값 전지적 실행 순서  
z = 2 a -> b -> c(d) -> d(c) x,y 결과가 정해진 이후 이기 때문에 c, d의 결과는 순서에 상관없이 같다.
z = 1 a -> c -> b -> d
b -> d -> a -> c
쓰레드 c는 y가 0으로 관측되기 때문에 z를 증가시키지 않는다. 혹은 그 반대.
z = 0 CPU 1: a -> c
CPU 2: b -> d
이러한 상황은 두 흐름이 다른 CPU에서 일어날 때 발생한다.

z = 0인 상황이 발생하는 이유

CPU의 계산 결과가 항상 메모리에 써지는 것은 아니다. 실제 CPU 명령어들을 빨리 처리하기 위해 메모리에 접근하는 것을 피하기 때문에 캐시에 두었다가 캐시에서 내보내질 때 메모리에 쓰는 방식 write-back을 많이 사용한다. 이런 경우 각 CPU마다 고유한 캐시를 사용할 경우 메모리에 직접 쓰기 전까지는 동기화가 안 되어 있을 수 있다.

 

기존 release-acquire는 store 이전의 상황이 load 시점에서 보이는 것을 보장하나 그러한 release-acquire 발생 사건이 모든 쓰레드에 보여지는 것은 아니다. 

 

 

예를 들어서

write_x 와 read_x_and_y 쓰레드가 코어 1 에,

write_y 와 read_y_and_x 쓰레드가 코어 2 에

배치되어 있다고 해봅시다.

 

코어 1 에서 write x 가 끝나고 load x 를 한 시점에서 x 의 값은 true 겠지요?

근데 acquire-release sync 는 해당 사실이 (x 가 true 라는게) 코어 2 에서도 visible 하다는 것은 보장되지 않습니다.

 

그쪽 세상에서는 x 가 아직 false 여도 괜찮습니다. 물론 여기서 acquire-release sync 에 위배되는 것은 전혀 없습니다. acquire-release 가 보장하는 것은 store x 전에 무언가 작업을 했다면 load x 할 때 해당 작업이 그 쓰레드에서 보여진다 라는 약속밖에 없죠.

 

 

반면 memory_order_seq_cst 는 어떤 아토믹 객체에 대한 실제 수정 순서가 모든 스레드에게 동일하게 보임을 보장한다.

 

atomoic 객체의 기본 memory_order 플래그의 기본 값은 memory_order_seq_cst이다.

 

 

하지만 memory_order_seq_cst 를 사용하게 된다면
해당 명령을 사용하는 메모리 연산들 끼리는 모든 쓰레드에서 동일한 연산 순서를 관찰할 수 있도록 보장해줍니다.
참고로 우리가 atomic 객체를 사용할 때, memory_order 를 지정해주지 않는다면 디폴트로 memory_order_seq_cst 가 지정이 됩니다. 예컨대 이전에 counter ++ 은 사실 counter.fetch_add(1, memory_order_seq_cst) 와 동일한 연산입니다.

 

 

문제는 멀티 코어 시스템에서 memory_order_seq_cst 가 꽤나 비싼 연산이라는 것입니다.

인텔 혹은 AMD 의 x86(-64) CPU 의 경우에는 사실 거의 순차적 일관성이 보장되서 memory_order_seq_cst 를 강제하더라도 그 차이가 그렇게 크지 않습니다. 하지만 ARM 계열의 CPU 와 같은 경우 순차적 일관성을 보장하기 위해서는 CPU 의 동기화 비용이 매우 큽니다. 따라서 해당 명령은 정말 꼭 필요 할 때만 사용해야 합니다.

#include <atomic>
#include <iostream>
#include <thread>
using std::memory_order_seq_cst;
using std::thread;

std::atomic<bool> x(false);
std::atomic<bool> y(false);
std::atomic<int> z(0);

void write_x() { x.store(true, memory_order_seq_cst); }

void write_y() { y.store(true, memory_order_seq_cst); }

void read_x_then_y() {
  while (!x.load(memory_order_seq_cst)) {
  }
  if (y.load(memory_order_seq_cst)) {
    ++z;
  }
}

void read_y_then_x() {
  while (!y.load(memory_order_seq_cst)) {
  }
  if (x.load(memory_order_seq_cst)) {
    ++z;
  }
}

int main() {
  thread a(write_x);
  thread b(write_y);
  thread c(read_x_then_y);
  thread d(read_y_then_x);
  a.join();
  b.join();
  c.join();
  d.join();
  std::cout << "z : " << z << std::endl;
}

위 코드는 memory_order_seq_cst 로 바꾼 코드이다

 

성공적으로 컴파일 하였다면

실행 결과

z : 2

혹은

실행 결과

z : 1

과 같이 나옵니다. x.store  y.store 가 모두 memory_order_seq_cst 이므로, read_x_then_y  read_y_then_x 에서 관찰했을 때 x.store  y.store 가 같은 순서로 발생해야 합니다. 따라서 z 의 값이 0 이 되는 경우는 발생하지 않습니다.

 

 

즉 다른 CPU 에서 

CPU 1: a -> c
CPU 2: b -> d

이렇게 돌아도 다른 cpu 에서 상대의 공유 값을 가시적으로 볼 수 있다는것(정확히 볼 수 있다는 것)

 

정리해보자면 다음과 같습니다.

연산허용된 memory order

쓰기 (store) memory_order_relaxed, memory_order_release, memory_order_seq_cst
읽기 (load) memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_seq_cst
읽고 - 수정하고 - 쓰기 (read - modify - write) memory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel, memory_order_seq_cst

 

참고로 memory_order_consume 은 다루지 않았는데 C++ 17 현재, memory_order_consume 의 정의가 살짝 수정 중에 있기에 memory_order_consume 의 사용이 권장되지 않습니다.

이렇게 C++ 에서 atomic 연산들에 대해 memory_order 을 지정하는 방법에 대해 알아보았습니다. C++ atomic 객체들의 경우 따로 지정하지 않는다면 기본으로 memory_order_seq_cst 로 설정되는데,

이는 일부 CPU 에서 매우 값비싼 명령 입니다. 만약에 제약 조건을 좀 더 느슨하게 할 수 있을 때 더 약한 수준의 memory_order 을 사용한다면 프로그램의 성능을 더 크게 향상 시킬 수 있습니다.

 

 

ref : https://modoocode.com/271

ref : https://narakit.tistory.com/139

반응형
반응형

코드 재배치 및 메모리 가시성문제 예제(https://3dmpengines.tistory.com/2197)

 

이 글에 이어가기 전에 약간 복습을 하고 가면..

 

동시에 값을 접근하여 읽는건 문제가 되지 않지만 쓸때 문제가 되고 

이때 경합이 일어난다 (Race Condition)

쓸때 아무 처리 하지 않으면 정의되지 않은 행동이 일어난다

 

결국엔 공용데이터에 접근 하려면 lock (mutex) 또는 atomic 연산을 이용하여 해결해야 한다

즉 lock 또는 원자적(Atomic = All or Nothing) 방식 중 하나를 선택해서 해결해야한다

원자적연산이란 더이상 쪼개 질수 없는 연산 즉 한번에 실행 될수 있는 연산을 말한다 꼭 atomic<> 을 말하는 것은 아님

 

 

본론으로 돌아와서...

 

Atomic 에 한하여 모든 스레드가 동일 객체에 대해 동일한 수정 순서를 관찰한다는 법칙이 있다

 

atomic<__int64> num;

void thread_1()
{
	num.store(1);
}

void thread_2()
{
	num.store(2);
}

void threadObserver()
{
	while (true)
	{
		__int64 value = num.load();
	}
}

 

thread_1 , thread_2 에서 값을 num 에 쓸때는 한번에 하나 밖에 쓰지 못한다

그런데 다른 스레드에서 값을 계속 관찰 한다고 했을때 value = num.load()  

thread_1, thread_2 의 스레드에서 연산이 이뤄지고 있어서 서로 다른 스레드에서 다른 값이 어떻게 수정되고 있는지에 대해서는 threadObserver 에서 그 값을 꺼내와서 관찰 할 수가 없다!

 

하지만 이때 동일한 객체에 대해서 동일한 수정 순서를 관찰 하는 것은 가능하다

 

 

A:

 

B:

 

변수에 대한 수정 순서가 0 , 2, 1, 5 의 순으로 수정되는 순서를 갖는다고 할때

A 에서 변수 값을 그 당시 현재 관찰할때 0을 관찰한 상황이라 가정한다

 

시간이 좀 지나서

B: 상태가 되고 화상표 있는 곳 까지 진행 되는 도중에 값을 관찰 할때는 다른 스레드에서는 값이 2로 관찰 될 수도 있고 또는 0으로 관찰 될 수가 있다는 것 (다른 코어 캐시에 의해) 이때 중요한것은 A 에서 이미 값은 0 이였음으로 0 이전에 어떤 값이 있었다면 그 이전으론 당연히 가지 않는다는 것이다, 0 을 포함 미래에 대한 것만 관찰이 가능하다는 것이다

 

그리고 관찰 되는 값은 B: 시점 기준에서

0 2,1,5

0,2 5

0, 1,5 

이렇게 미래에 대한것들로 관찰 될 수가 있다

 

 

 

 

C++ 의 모든 객체들은 수정 순서(modification order) 라는 것을 정의할 수 있습니다. 수정 순서라 하는 것은, 만약에 어떤 객체의 값을 실시간으로 확인할 수 있는 전지전능한 무언가가 있다고 하였을 때, 해당 객체의 값의 변화를 기록한 것이라 보면 됩니다. (물론 실제로 존재하지 않고, 가상의 수정 순서가 있다고 생각해봅시다.)

 

a 의 수정 순서

 

만약에 위 처럼, 어떤 변수 a 의 값이 위와 같은 형태로 변화해왔다고 해봅시다.

 

C++ 에서 보장하는 사실은, 원자적 연산을 할 경우에 모든 쓰레드에서 같은 객체에 대해동일한 수정 순서 를 관찰할 수 있다는 사실입니다.

여기서 강조할 점은 순서 가 동일하다는 것이라는 점입니다.

 

쉽게 말해 어떤 쓰레드가 a 의 값을 읽었을 때, 8 로 읽었다면, 그 다음에 읽어지는 a 의 값은 반드시 8, 6, 3 중에 하나여야 할 것입니다. 수정 순서를 거꾸로 거슬러 올라가서 5 를 읽는 일은 없습니다.

 

모든 쓰레드에서 변수의 수정 순서에 동의만 한다면 문제될 것이 없습니다.

이 말이 무슨 말이냐면, 같은 시간에 변수 a 의 값을 관찰했다고 해서

굳이 모든 쓰레드들이 동일한 값을 관찰할 필요는 없다 라는 점입니다.

 

예를 들어서 정확히 같은 시간에 쓰레드 1 과 2 에서 a 의 값을 관찰하였을 때

쓰레드 1 에서는 5 를 관찰하고,

쓰레드 2 에서는 8 을 관찰해도 문제될 것이 없습니다.

 

심지어, 동일한 코드를 각기 다른 쓰레드에서 실행하였을 때, 실행하는 순서가 달라도 (결과만 같다면) 문제가 안됩니다.

쓰레드 간에서 같은 시간에 변수의 값을 읽었을 때 다른 값을 리턴해도 된다는 점은 조금 충격적입니다.

하지만, 이 조건을 강제할 수 없는 이유는 그 이유는 아래 그림 처럼 CPU 캐시가 각 코어별로 존재하기 때문입니다.

Cache (2) - 코어당 캐시메모리 (https://3dmpengines.tistory.com/2194) 참고

 

 

코어 각각 L1, L2 캐시를 가지고 있다.

 

 

보시다시피, 각 코어가 각각 자신들의 L1, L2 캐시들을 갖고 있는 것을 알 수 있습니다.

따라서, 만약에 쓰레드 1 에서 a = 5 을 한 후에 자신들의 캐시에만 기록해 놓고 다른 코어들에게 알리지 않는다면,

쓰레드 3 에서 a 의 값을 확인할 때, 5 를 얻는다는 보장이 없다는 이야기 입니다.

물론, 매번 값을 기록할 때 마다, 모든 캐시에 동기화를 시킬 수 있겠지만 동기화 작업은 시간을 꽤나 잡아먹는 일입니다. 다행이도, C++ 에서는 로우 레벨 언어 답게, 여러분들이 이를 세밀하게 조정할 수 있는 여러가지 도구들을 제공하고 있습니다.

 

 

 

앞서 이야기 했듯이, C++ 에서 모든 쓰레드들이 수정 순서에 동의해야만 하는 경우는

바로 모든 연산들이 원자적 일 때 라고 하였습니다.

 

원자적인 연산이 아닌 경우에는 모든 쓰레드에서 같은 수정 순서를 관찰할 수 있음이 보장되지 않기에 여러분이 직접 적절한 동기화 방법을 통해서 처리해야 합니다. 만일 이를 지키지 않는다면, 프로그램이 정의되지 않은 행동(undefined behavior)을 할 수 있습니다. ( 즉 한번에 연산 되어야 하는 것에만 해당 된다는 얘기 )

 

 

그렇다면 원자적 이라는 것이 무엇일까요?

이미 이름에서 짐작하셨겠지만, 원자적 연산이란, CPU 가 명령어 1 개로 처리하는 명령으로, 중간에 다른 쓰레드가 끼어들 여지가 전혀 없는 연산을 말합니다. 즉, 이 연산을 반 정도 했다 는 있을 수 없고 이 연산을 했다 혹은 안 했다 만 존재할 수 있습니다. 마치 원자처럼 쪼갤 수 없다 해서 원자적(atomic) 이라고 합니다.

 

하지만 원자적 연산 자체가 바로 문제를 해결해 주진 않는다

 

 

 

 

Atomic 연산은 모든 쓰레드가 동일 객체에 대해서 동일한 수정 순서를 관찰 할수 있게 한다는것이 기본 법칙

 

 

원자적 Atomic 에 대한 예시를 한번 살펴보자

 

 

원자 연산의 특성 1

동일한 수정 순서란 무엇인가?

atomic<__int64> num;

void thread_1()
{
	num.store(1);
}

void thread_2()
{
	num.store(2);
}



//관찰자 스레드로 실시간으로 num 값을 관찰하고 있다고 가정한다
void threadObserver()
{
	while (true)
	{

		//이때 읽어오는 값은 어떻게 되는가?
		//atomic 또한 다른 쓰레드에서 값을 스고 있지만 코어당 캐쉬가 별도 있고  cpu 와 캐쉬 간의 연산할때의 값과 램에있는
		//값을 threadObserver 에서 관찰할떄의 가시성 때문에
		//이 쓰래드에선 순간적으로 num 값이 cpu와 캐쉬에서 현재 연산하고 있는 값과 다를 수 있다
		// 
		//하지만 동일한 수정 순서를 관찰 할수 있는데 이것은 값이 변경 될때 현재와 앞으로의 값중에
		//어떤 값이 될지모르지만 그중에 하나를 얻을수 있다는 것인데 과거 것은 안된다는 것
		//수정순서는 이런 얘기

		__int64 value = num.load();

	}
}

while(true)

{

value  이때 읽어오는 값은 어떻게 되는가?
atomic 또한 다른 쓰레드에서 값을 쓰고 있지만 코어당 캐쉬가 별도 있고  cpu 와 캐쉬 간의 연산할때의 값과 램에있는
값을 threadObserver 에서 관찰할떄의 가시성 때문에
이 쓰래드에선 순간적으로 num 값이 cpu와 캐쉬에서 현재 연산하고 있는 값과 다를 수 있다
  
하지만 동일한 수정 순서를 관찰 할수 있는데 이것은 값이 변경 될때 현재와 앞으로의 값중에
어떤 값이 될지모르지만 그중에 하나를 얻을수 있다는 것인데 과거 것은 안된다는 것
동일한 수정 순서는 이런 얘기

  __int64 value = num.load();

 

 

 

 

원자 연산의 특성 2

 

C++ 에서는 몇몇 타입들에 원자적인 연산을 쉽게 할 수 있도록 여러가지 도구들을 지원하고 있습니다.

또한 이러한 원자적 연산들은 올바른 연산을 위해 굳이 뮤텍스가 필요하지 않습니다! 즉 속도가 더 빠릅니다.

 

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

void worker(std::atomic<int>& counter) {
  for (int i = 0; i < 10000; i++) {
    counter++;
  }
}

int main() {
  std::atomic<int> counter(0);

  std::vector<std::thread> workers;
  for (int i = 0; i < 4; i++) {
    workers.push_back(std::thread(worker, ref(counter)));
  }

  for (int i = 0; i < 4; i++) {
    workers[i].join();
  }

  std::cout << "Counter 최종 값 : " << counter << std::endl;
}

 

void worker(std::atomic<int>& counter) {

가 지역변수인 

std::atomic<int> counter(0);

를 받고 공용으로 수정하려고 함으로 atomic 을 사용 한것 (전역 변수로 사용한것과 엇 비슷한 효과)

 

출력 결과는 

Counter 최종 값 : 40000

 

 

놀랍게도 counter ++; 을 아무런 뮤텍스로 보호하지 않았음에도 불구하고, 정확히 Counter  40000 으로 출력 되었습니다. 원래 counter ++ 을 하기 위해서는 CPU 가 메모리에서 counter 의 값을 읽고 - 1 더하고 - 쓰는 총 3 개의 단계를 거쳐야만 했습니다. 그런데, 여기서는 lock 없이도, 제대로 계산하였지요.

 

 

 

그렇다면 컴파일러는 이를 어떻게 원자적 연산으로 만들었을까요? 이를 알기 위해서는 다시 컴파일러가 어떤 어셈블리 코드를 생성했는지 살펴봐야 합니다.

붉은색 테두리가 counter ++ 부분이다.

놀랍게도 counter ++ 부분이 실제로 어셈블리 명령 한 줄인

  lock add DWORD PTR [rdi], 1

로 나타남을 알 수 있습니다. 원래 CPU 는 한 명령어에서 메모리에 읽기 혹은 쓰기 둘 중 하나 밖에 하지 못합니다. 메모리에 읽기 쓰기를 동시에 하는 명령은 없습니다. 하지만, 이 lock add 의 경우 rdi 에 위치한 메모리를 읽고 - 1 더하고 - 다시 rdi 에 위치한 메모리에 쓰기를 모두 해버립니다.

참고로 이러한 명령어를 컴파일러가 사용할 수 있었던 이유는 우리가 어느 CPU 에서 실행할 지 (x86) 컴파일러가 알고 있기 때문에 이런 CPU 특이적인 명령어를 제공할 수 있던 것입니다. 물론, CPU 에 따라 위와 같은 명령이 없는 경우도 있습니다.

이 경우 CPU 는 위와 같은 원자적인 코드를 생성할 수 없습니다. 이는 해당 atomic 객체의 연산들이 과연 정말로 원자적으로 구현될 수 있는지 확인하는 is_lock_free() 함수를 호출해보면 됩니다. 예를 들어서

 

std::atomic<int> x;
std::cout << "is lock free ? : " << boolalpha << x.is_lock_free() << std::endl;

를 실행해보면

실행 결과

Is lock free ? : true

와 같이 나옵니다. 여기서 lock free  lock 과 실제 어셈블리 명령에서의 lock 과는 다른 lock 을 의미합니다.

위 어셈블리 명령어에서의 lock 은 해당 명령을 원자적으로 수행하라는 의미로 사용되고, 

lock free 에서의 lock 이 없다 라는 의미는 뮤텍스와 같은 객체들의 lock, unlock 없이도 해당 연산을 올바르게 수행할 수 있다는 뜻입니다.

 

 

 

struct Knight

{

  int32 level;

  int32 hp;

  int32 mp;

};

 

atomic<Knight> v;

cout<< v.is_lick_free() << endl;

 

이 결과는 false 가 나오는데 즉 원자적으로 연산을 일반적으론 할 수없어서 atocmi<> 내에서 뮤텍스처럼 lock 을 잡고 값을 수정한다음 다시 unlock 하는 식으로 내부적으로 구현 처리가 된다

 

 

 

ref : https://modoocode.com/271

반응형
반응형

 

아래 코드를 실행 하면 이코드는 싱글 스레드에선 실행되지 않는 코드 이지만

코드 재배치 또는 메모리 가시성 문제로 인하여 멀티 스레드 환경에서

실행 되는 상황이 발생 될 수도 또는 발생 되지 않을 수도 있다

 

아래 코드가 종료 되는 이유는 크게 두가지가 있다

 

먼저 알아야 햐는 부분이 각 코어마다 별도의 캐쉬를 갖고 있는다
(https://3dmpengines.tistory.com/2194)


문제가 될만한 가능성은 두가지이다

  1. 가시성 (https://3dmpengines.tistory.com/2195)
    = 캐쉬와 램 사이에 데이터가 보이는가

    cpu 가 어떤 값을 쓰거나 읽을때 램까지 가서 읽어올수도 있는데
    이것은 캐쉬에 없을때 얘기다 , 이때 CPU 는 각 코어마다 별도의 캐쉬를 갖고 있는데 
    실제 램까시 가서 데이터를 불러와서 변수에 쓴건지에 대한 보장이 없을 수 있다
    코어와 스레드는(t1) 하나로 묶여서 연산 되고 cpu 에서 캐쉬의 데이터를 연산을 하면서 아직 Ram 에 데이터를 쓰지 않았을 수 있는데
    이때 다른 쓰레드(t2)에서 현재 t1 에서 아직  공유변수의 연산중인 값의 최종을 모르고 이전 램에 있는 값만 알기 때문에 값이 정확하게 일치하지 않는다는 문제가 비가시성을 말함

  2. 코드 재배치 (https://3dmpengines.tistory.com/2196)
    = 결과가 동일하다면, 컴파일러가 코들르 보고 더 빠를 것 같으면 코드 순서를 바꿀 수도 있다
     이것 때문에 값이 제대로 원하는 값이 아닐 수도 있다, 코드로 보는것과 달리
    그런데 이 코드 재배치는 컴파일러 뿐만 아니라 CPU 에서 멋대로 제배치 할 수도 있다


과연 컴파일러만 재배치를 할까?

한 가지 더 재미있는 점은, 꼭 컴파일러만이 명령어를 재배치하는게 아니라는 점입니다. 예를 들어서 다음과 같은 두 명령을 생각해봅시다.

 C/C++ 확대 축소
// 현재 a = 0, b = 0;
a = 1;  // 캐시에 없음
b = 1;  // 캐시에 있음

a = 1 의 경우 현재 a 가 캐시에 없으므로, 매우 오래 걸립니다. 반면에 b = 1; 의 경우 현재 b 가 캐시에 있기 때문에 빠르게 처리할 수 있겠지요. 따라서 CPU 에서 위 코드가 실행될 때, b = 1;  a = 1; 보다 먼저 실행될 수 있습니다.

따라서, 다른 쓰레드에서 a 는 0 인데, b 가 1 인 순간을 관찰할 수 있다는 것입니다.

 

 

 

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>	
#include <mutex>
#include "AccountManager.h"
#include "UserManager.h"
#include <chrono>
#include <future>
#include "windows.h"

using namespace std;


int32 x = 0;
int32 y = 0;
int32 r1 = 0;
int32 r2 = 0;


volatile bool ready;


void Thread_1()
{
	while (!ready)
	{
		volatile int i = 39;
		i += 10;
	}
    
    //가시성 관점으로 봤을때 특정 시점에서 이것 또한 램까지 가서 
    //y 에 데이터를 썼다는 보장이 없을 수 있다 
	y = 1;	//store y	
	r1 = x;	//load x
}

void Thread_2()
{
	while (!ready)
	{
		volatile int i = 39;
		i += 10;
	}
	x = 1;
    
    //가시성 문제로 봤을때 특정 시점에서 y 는 1이 아닐 수도 있다
	r2 = y;				
}

int main()
{

	int32 count = 0;
	while (true)
	{
		ready = false;
		count++;

		x = y = r1 = r2 = 0;
		thread t1(Thread_1);
		thread t2(Thread_2);
		ready = true;

		t1.join();
		t2.join();

		if (r1 == 0 && r2 == 0)
		{
			break;
		}
	}

	std::cout << count << std::endl;



	return 0;
}

 

ready 하단에 들어간 volatile 은 테스트를 위해 의미 없이 넣은 코드조각이다

 

ref :https://modoocode.com/271

반응형
반응형

 

 

아래 4개의 빨래 일감이 있는데 빨래 하나만 들고 빨래가 다 끝나면 다리미 하고 건조 하고 여기 까지 완료 된다음 다음빨래를 들고가면 비효율적이니 빨래 하나를 세틱기 하나에 넣고 그다음 빨래도 세틱기에 넣고

앞의 빨래가 끝나면 건조기에 넣고 바로 그다음 빨래를 세틱기에 넣고 하는 식으로 낭비 없이 명령어를 처리 하는것이 CPU 파이프라인이다


 그래서 생각해본 방법 중 하나가 한가지 작업을 수행한 후에는 다른 Instruction을 동시에 수행할 수 있게 하자는 아이디어다. 그림으로 표현하면 다음과 같다.

 

 

어차피 동시에 같은 작업을 수행하는 게 아니라면 순차적으로 다음 instruction이 수행할 수 있도록 배려하자는 것이다. 이러면 D가 끝나기 까지 걸리는 시간이 앞의 Sequential Execution 방식에 비해 약 절반으로 줄어들게 된다. 마치 이런 방식이 pipeline에 Instruction을 꾸역꾸역 집어넣는 방식같다고 해서 Pipelined Execution 이라고 한다. 꾸역꾸역 집어넣으니 먼저 집어넣은 Instruction은 먼저수행되고 빈틈없이 다음 Instruction이 수행될 것이다. 그러면 맨 앞에서 언급했던 것처럼 세탁기와 건조기가 Time을 Waste하는 일은 없을 것이다. 이걸 Computer에서는 다음과 같이 구현된다.

 

 

결론적으로 pipeline을 통해서 추구하고자 했던 바는 한번에 처리할 수 있는 instruction의 수(stage)를 늘림으로써 instruction에 대한 throughput, 즉 정해진 시간동안 instruction을 얼마나 처리할 수 있는지를 높이기 위해서 였다고 할 수 있다. 유의할 것은 한개의 instruction이 처리되는 속도가 증가하는 것이 아니라 동시에 처리할 수 있는 instruction 수를 늘려서 전체 instruction이 처리되는 속도를 증가시키는 효과를 얻는 것이다. 참고로 위의 예시는 MIPS의 5-stage pipelined 구조이고, Pentium 4의 pipeline은 31 stage였다. 그런데 연구결과 무조건 stage만을 늘려서 효율성을 얻기에는 한계가 있어서 다른 부분을 발전시키는 방향으로 발전해가고 있으며, 현재 출시된 core i7의 stage는 16이다. 

 


 

 

그런데 각각 빨라마다 걸리는 시간이 다를 수 있어서 어떤걸 먼저 넣고 어떤걸 나중에 넣어야 더빠른 상황이 될 수 있는데 이와 같은 상황이 코드 재배치에 해당한다 (컴파일러와 CPU 에서 명령어를 바꾸는 상황)

 

CPU 파이프라인은 위 처럼 생겼는데 

명령 처리가  명령을 읽어오는 Fetch ,  명령을 해석하는 Decode, 명령을 실행하는 Execute, Register Write Back (WB)

이때 명령어를 바꿔서 더 빠르게 동작하게 할수 있다면 바꿔주는 것이 코드 재배치

 

즉 결과가 동일하다면, 컴파일러가 코들르 보고 더 빠를 것 같으면 코드 순서를 바꿀 수도 있다
 이것 때문에 값이 제대로 원하는 값이 아닐 수도 있다, 코드로 보는것과 달리
그런데 이 코드 재배치는 컴파일러 뿐만 아니라 CPU 에서 멋대로 제배치 할 수도 있다

 

 

- Instruction Fetch (IF)

-> Instruction Decode / Register Fetch (ID)

-> Instruction Execution (EX)

-> Memory Access (MEM)

-> Register Write Back (WB)

 

 

 

 

ref : https://talkingaboutme.tistory.com/entry/Study-Pipeline

반응형
반응형

프롤로그

 가시성과 원자성 이 두가지를 이해 하면 Multi-thread프로로그램을 작성할 때 무엇을 주의해하는지 명확해진다. 또 다른 표현으로 설명 하자면 단일 Thread 프로그램에서는 가시성과 원자성을 괘념치 않아도 프로그램 작성하는데 문제가 없다.  그렇다고 해서 가시성과 원자성이 Multi-thread를 할 때만 뚜둥 나타나는 개념은 아니라는점 명확히 밝혀둔다. 하드웨어를 설계한 사람들에 의해 만들어진 원래 컴퓨터 내의 구조에 관한 이야기다. 이번 편에서는 지난편에서 설명한 이 두 가지 개념중에 가시성을 좀더 깊게 파고들어보려 한다.

 

비 가시성(가시성 이슈)

 다른 자료나 책을 통해 가시성에 대한 개념을 이해하고 있는 독자라면 필자가 외 비 가시성이라는 반대가 되는 단어를 만들어 썼는지 감이 왔을것이다. 지난 편에도 아래 그림이 등장했는데 다시한번 고찰 해보자. 

 


비 가시성 이슈

 

 각기 다른  Thread 2개는 CPU 1과 CPU2를 할당 받아 공유자원에 해당하는 변수를 연산한다.

이 때 메인 메모리에서 값을 읽어 연산에 사용하는 것이 아니라 가 CPU에 존재하는 Cache에 옮겨놓고 연산을 한다.

그 사이에 다른 쓰레드에서 같은 변수를 대상으로 연산을 한다. 이 때 Thread는 타 Thread가 사용하고 있는 CPU의 Cache상의 값을 알지 못한다. 언제 Cache의 값이 메인 메모리로 쓰여질지도 모른다.

그래서 아래코드를 돌려보면 각각의 Thread가 100회씩 변수를 증가연산을 실시 했는데 200에 휠씬 못 미치는 103 ~ 105쯤이 나타난다. (이 실험 결과는 하드웨어의 성능에 따라 상이 할 수 있으니 참고 바란다.) 만약 동시에 시작하는 Thread가 아닌 순차 처리라면 200이 나와야 하는 현상이다. Cache에 담아서 연산을 하더라도 바로 바로 메모리에 적용을 했으면 200은 아니더라도 180~190정도는 날올것 같은데 너무나도 100가까운 결과에 놀라는 독자도 있을 것이다. 다음 단계에서 이 문제를 조금(?) 해결 해보자.

 

 

가시성이란 멀티 코어의 캐시(Cache) 간 불일치의 문제를 말하는데, 하나의 스레드에서 특정한 변수의 값을 수정하였는데, 다른 스레드에서 그 수정된 값을 제대로 읽어 들인다는 보장이 없다.

(참고로 C++의 volatile은 JAVA와는 달리 가시성 문제를 해결해주지 않는다. 단순히 컴파일러 최적화를 막기만 함)

 

* Java 에서 가시성 관련된 이야기 시작
암튼 JAVA에선 위에서 설명한 비 가시성 이슈를 해결하기 위해 JAVA 1.4 부터 volatile을 지원하기 시작 했다. 변수를 선언 할 때 해당 단어를 앞에 써주기만 하면 되는데 이렇게만 해도 위 테스트 코드가 거의 200을 반환한다. 
 (사실 이 결과는 미신같은 이야기다. 정확히 이야기 하면 200이거나 200보다 작은 값을 반환한다.) 원리는 이렇다. 
Java 에선 volatile로 선언된 변수를 CPU에서 연산을 하면 바로 메모리에 쓴다. (Cache에서 메모리로 값이 이동하는 것을 다른 책이나 문서에서는 flush라고 표현한다.) 그러니 운이 좋게 Thread 두 개가 주거니 받거니 하면서 증가를 시키면 200에 가까운 결과를 얻어내는 것이다.

 하지만 개발자라면 이 미신같은 결과에 흡족해 하면 안된다. 필자의 PC를 기준으로 각 Thread당 100회가 아닌 1000회정도 연산을 시키면 2000이 아닌 1998같은 결과를 얻어낸다. 
이 이야기인 즉 가시성이 확보된다 하더라도 원자성 문제(동시에 같은 값을 읽어다 증가시키고 flush하는...)로 인해 이와 같은 문제가 생기는 것이다. 

이 문제는 원자성 다루면서 해결 해보자.

* Java 에서 가시성 관련된 이야기 끝

 

 

 

아래 코드는 자바이지만 제대로 되지 않는 다는 정도만 보면 된다

private int count = 0;

    @Test
    public void Test_AtomicIssue() {
        ExecutorService es = Executors.newFixedThreadPool(2);

        es.execute(new ForThreadTest());
        es.execute(new ForThreadTest());

        es.shutdown();

        try {
            es.awaitTermination(10, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("TEST result >>> " + count);
    }

    class ForThreadTest implements Runnable {
        @Override
        public void run() {
            for(int i = 0 ; i < 100; i++) {
                AtomicStampedRefTest.this.count++;
            }
        }
    }

 

 

 

ref :https://marmelo12.tistory.com/320

ref : https://rightnowdo.tistory.com/entry/JAVA-concurrent-programming-Visibility%EA%B0%80%EC%8B%9C%EC%84%B1

반응형
반응형

모든 최신 프로세서는 소량의 캐시 메모리를 특징으로 한다. 지난 수십 년 동안 캐시 아키텍처는 점점 더 복잡해졌다. CPU 캐시의 레벨이 증가했고, 각 블록의 크기가 커졌으며, 캐시 연관성도 몇 가지 변화를 겪었다. 자세한 내용을 살펴보기 전에, 메인 메모리와 캐시 메모리의 차이점은 무엇인가? 이미 RAM을 가지고 있는데 왜 소량의 캐시 메모리가 필요하십니까? 한 마디: 속도!

 

 

캐시 메모리 대 시스템 메모리: SRAM VS DRAM

캐시 메모리는 훨씬 더 빠르고 비싼 정적 RAM을 기반으로 하며 시스템 메모리는 더 느린 D램(Dynamic RAM)을 활용한다. 두 가지 주요 차이점은 전자가 CMOS 기술과 트랜지스터(블록당 6개)로 만들어진 반면 후자는 콘덴서와 트랜지스터를 사용한다는 점이다.

 

데이터를 장기간 보존하려면 D램을 지속적으로 새로 고쳐야 한다. 이 때문에 훨씬 더 많은 전력을 소비하고 또한 더 느리게 된다. SRAM은 새로 고칠 필요가 없고 훨씬 더 효율적이다. 그러나 가격이 높아져 주류 채택이 금지되어 프로세서 캐시로 사용이 제한되고 있다.

 

프로세서에서 캐시 메모리의 중요성?

현대의 프로세서는 80년대와 90년대 초반의 원시 조상들보다 몇 년 앞선다. 오늘날 대부분의 DDR4 메모리 모듈은 1800MHz 미만으로 정격인 반면, 최고급 소비자 칩은 4GHz 이상에서 작동한다. 그 결과 시스템 메모리가 너무 느려서 CPU를 심하게 늦추지 않고 직접 작업할 수 없다. 캐쉬 메모리가 들어오는 곳이 바로 여기에 있다. 그것은 반복적으로 사용된 데이터의 작은 덩어리를 저장하거나 경우에 따라 그 파일의 메모리 주소를 저장하면서 둘 사이의 중간 역할을 한다.

 

 

L1, L2 및 L3 캐시: 차이점은?

현대 프로세서에서 캐시 메모리는 크기를 증가시키고 속도를 감소시키기 위해 L1, L2, L3 캐시의 세 부분으로 나뉜다. L3 캐시는 가장 크고 가장 느리다(3세대 Ryzen CPU는 최대 64MB의 L3 캐시를 특징으로 한다). L2와 L1은 L3보다 훨씬 작고 빠르며 각 코어에 대해 분리되어 있다. 구형 프로세서에는 3단계 L3 캐시가 포함되지 않았으며 L2 캐시와 직접 상호 작용하는 시스템 메모리:

 

L1 캐시는 L1 데이터 캐시와 L1 명령 캐시의 두 가지 섹션으로 더 세분된다. 후자는 CPU에 의해 실행되어야 하는 지시사항을 포함하고 전자는 메인 메모리에 다시 기록될 데이터를 보유하는 데 사용된다.

 

L1 캐시는 명령 캐시 역할을 할 뿐만 아니라 사전 디코드 데이터와 분기 정보도 가지고 있다. 또한 L1 데이터 캐시가 출력 캐쉬 역할을 하는 경우가 많지만 명령 캐시는 입력 캐쉬처럼 동작한다. 이는 필요한 지침이 가져오기 장치 바로 옆에 있으므로 루프가 체결될 때 유용하다.

 

 

최신 CPU는 플래그십 프로세서의 경우 최대 512KB의 L1 캐시(코어당 64KB)를 포함하며 서버 부품은 거의 두 배나 많은 기능을 한다.

 

L2 캐시는 L1보다 훨씬 크지만 동시에 느리다. 플래그십 CPU에서 4~8MB(코어당 512KB)로 다양하다.

각 코어는 자체 L1 및 L2 캐시를 가지고 있으며, 마지막 레벨인 L3 캐시는 모든 코어에 걸쳐 공유된다.

 

L3 캐시는 가장 낮은 수준의 캐시다. 10MB에서 64MB까지 다양하며, 서버 칩은 256MB의 L3 캐시를 특징으로 한다. 더욱이 AMD의 라이젠 CPU는 경쟁사인 인텔 칩에 비해 캐시 크기가 훨씬 크다. 인텔 측의 MCM 디자인 대 모놀리성 때문이다. 자세한 내용은 여기를 참조하십시오.(https://www.flayus.com/55228516

 

)

 

CPU가 데이터를 필요로 할 때, 먼저 관련 코어의 L1 캐시를 검색한다. 발견되지 않으면 다음에 L2와 L3 캐시가 검색된다. 필요한 자료가 발견되면 캐시히트라고 한다. 한편, 캐시에 데이터가 없을 경우 CPU는 메인 메모리나 스토리지에서 캐시에 로딩되도록 요청해야 한다. 이것은 시간이 걸리고 성능에 악영향을 미친다. 이것을 캐시 미스라고 한다.

 

일반적으로 캐시 크기가 증가하면 캐시 적중률이 향상된다. 특히 게임 및 기타 지연 시간에 민감한 워크로드의 경우 더욱 그러하다.

 

 

메모리 매핑

기본적인 설명은 생략하고, 시스템 메모리가 캐시 메모리와 어떻게 대화하는지에 대해 이야기해 보자. 캐시 메모리는 블록으로 나뉜다. 이 블록들은 n개의 64바이트 선으로 나누어 회전하고 있다.

시스템 메모리는 캐시와 동일한 수의 블록으로 나뉘고 그 다음 두 개가 연결된다.

 

1GB의 시스템 RAM이 있으면 캐시는 8192줄로 나뉘고 블록으로 분리된다. 이것을 n-way 연관 캐시라고 한다. 2방향 연관 캐시의 경우, 각 블록에는 각각 2개의 선이 있으며, 4방향에는 각각 4개, 8방향에는 8개, 16개 라인이 포함되어 있다. 총 RAM 크기가 1GB인 경우 메모리의 각 블록 크기는 512KB가 된다.

 

512KB의 4방향 관련 캐시가 있는 경우, RAM은 2,048개의 블록(1GB의 경우 8192/4)으로 나뉘며 동일한 수의 4라인 캐시 블록에 연결된다.

 

 

16방향 연관 캐시와 동일한 방식으로, 캐시는 메모리의 512(2048KB) 블록에 연결된 512개의 블록으로 나뉘며, 각 캐시 블록은 16개의 선을 포함한다. 캐시에 데이터 블록이 부족하면 캐시 컨트롤러는 프로세서 실행을 계속하는 데 필요한 데이터가 포함된 새로운 블록 집합을 다시 로드한다.

 

N-way 연관 캐시는 가장 일반적으로 사용되는 매핑 방법이다. 직접 매핑과 완전히 연관된 매핑으로 알려진 두 가지 방법이 더 있다. 전자에서는 캐시 라인과 메모리 사이에 하드 링크가 있는 반면 후자의 경우 캐시는 어떤 메모리 주소도 포함할 수 있다. 기본적으로 각 라인은 어떤 메인 메모리 블록에도 접근할 수 있다. 이 방법은 적중률이 가장 높다. 하지만, 구현하는 것은 비용이 많이 들고 대부분 반도체 제조업체들이 피한다.

 

완전히 연결된 매핑

 

ref : https://www.flayus.com/it/55228456

반응형
반응형

메모리는 엄청 느리다.

기본적으로 CPU 와 컴퓨터 메모리인 RAM 은 물리적으로 떨어져 있습니다. 따라서 CPU 가 메모리에서 데이터를 읽어 오기 위해서는 꽤 많은 시간이 걸립니다. 실제로, 인텔의 i7-6700 CPU 의 경우 최소 42 사이클 정도 걸린다고 보시면 됩니다. CPU 에서 덧셈 한 번을 1 사이클에 끝낼 수 있는데, 메모리에서 데이터 오는 것을 기다리느라, 42 번 덧셈을 연산할 시간을 놓치게 되는 것입니다.

이는 CPU 입장에 굉장한 손해가 아닐 수 없습니다. 메모리에서 데이터 한 번 읽을 때 마다 42 사이클 동안 아무것도 못한다니 말입니다.

 

이래서 캐시가 존재하는 것

 

cache

1) Temporal Locality : 방금 사용한것을 또 사용할 확률이 높다는 것

2) Spatial Locality : 한번 사용한 메모리 근방에의 것들 다시 사용할 확률이 높다는 것

 

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>	
#include <mutex>
#include "AccountManager.h"
#include "UserManager.h"
#include <chrono>
#include <future>
#include "windows.h"

using namespace std;


int32 buffer[10000][10000] = { {0,} ,};

int main()
{

	{
		uint64 start = GetTickCount64();

		int64 sum = 0;
		for (int32 i=0;i<10000;++i)
		{
			for (int32 j = 0; j < 10000; ++j)
			{
				sum += buffer[i][j];
			}
		}

		uint64 endt = GetTickCount64();
		std::cout << endt - start << std::endl;
	}

	{
		uint64 start = GetTickCount64();

		int64 sum = 0;
		for (int32 i = 0; i < 10000; ++i)
		{
			for (int32 j = 0; j < 10000; ++j)
			{
				sum += buffer[j][i];
			}
		}

		uint64 endt = GetTickCount64();
		std::cout << endt - start << std::endl;
	}
	


	return 0;
}

 

위 코드 실행 결과를 보면 다음과 같은 시간 차이를 보인다

 

i, j 순서만 바꿨을뿐인데 왜 시간 차이가 이렇게 나는 것인가?

 

2차원 배열은 사실상 1차원 배열이다 

하지만 j 칸 을 앞부분 배열 인덱스로 지정해주면 근방의 메모리가 캐쉬에 있지 않기 때문에 캐쉬 로스로 이런 성능 저하가 발생한다 => 캐쉬에 없다면 램 까지 갔다 와야 함으로 느려짐

 

 

각 코어는 자체 L1 및 L2 캐시를 가지고 있으며, 마지막 레벨인 L3 캐시는 모든 코어에 걸쳐 공유된다.

자세한 설명은 Cache (2) (https://3dmpengines.tistory.com/2194)

 

 

ref : https://modoocode.com/271

반응형
반응형

 

future, async 는 mutex, condition variable 까지 쓰지 않고 단순한 쓰레드 처리시 유용하다

1회성 사용시에 유용 

 

ex)파일로딩

 

1) async : 원하는 함수를 비동기적으로 실행

2) promise : 결과물을 promise를 통해 future 를 통해 받아줌

3) packged_task : 원하는 함수의 실행 결과를 packged_task를 통해 future 로 받아 줌

2,3은 비슷하다

 

비동기 != 멀티쓰레드 

단지  용어 상의 차이인데 비동기는 나중에 함수를 호출하는 개념

즉 지연 호출의 개념이 있어서 완전 멀티 스레드라고 할 수는 없다 

 

 

wait_for

연결된 비동기 작업이 완료되거나 지정된 _Rel_time 시간이 경과할 때까지 차단합니다.

구문

C++복사
template <
    class _Rep,
    class _Period
>
std::future_status::future_status wait_for(
    const std::chrono::duration< _Rep, _Period>& _Rel_time ) const;

매개 변수

_Rep
틱 수를 나타내는 산술 형식입니다.

_Period
틱당 경과된 시간(초)을 나타내는 std::ratio입니다.

_Rel_time
작업이 완료될 때까지 대기하는 최대 시간입니다.

반환 값

HRESULT = NO_ERROR를

  • std::future_status::deferred 연결된 비동기 작업이 실행되고 있지 않으면
  • std::future_status::ready 연결된 비동기 작업이 완료된 경우
  • std::future_status::timeout 지정된 기간이 경과된 경우

 

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>	
#include <mutex>
#include "AccountManager.h"
#include "UserManager.h"
#include <chrono>
#include <future>
#include "windows.h"

using namespace std;

int64 calculate()
{
	int64 sum = 0;
	for (int32 i = 0; i < 100'000; ++i)
	{
		sum += 1;
	}
	return sum;
}


int main()
{
	{
		//비 동기 호출, calculate 가 호출 
		std::future<int64> future = std::async(std::launch::async, calculate);		

		std::future_status status = future.wait_for(1ms);	//1밀리 세컨동안 대기했다가 상태를 얻어와서

		//지금 스레드의 상태가 어떤 상태인지 알 수 있다
		//std::future_status::ready, std::future_status::deferred, std::future_status::timeout
		/*
		*	std::future_status::deferred 연결된 비동기 작업이 실행되고 있지 않으면

			std::future_status::ready 연결된 비동기 작업이 완료된 경우

			std::future_status::timeout 지정된 기간이 경과된 경우
		*/
		if(status == std::future_status::ready)		//ready 면 일감이 완료  된상태
		{
			
		}


		int64 sum = future.get();		//get 을 만나면 이때까지 스레드가 실행 안됐다면 대기했다가 결과 반환


	}

	return 0;
}

 

ready 면 일감이 완료된 상태

 

 

 

future.wait() == wiat_for 를 무한정 기다리는 상태

		//비 동기 호출, calculate 가 호출 
		std::future<int64> future = std::async(std::launch::async, calculate);		

		//std::future_status status = future.wait_for(1ms);	//1밀리 세컨동안 대기했다가 상태를 얻어와서
		future.wait();		//wiat_for 를 무한정 기다리는 상태
		
		//그래서  wait 을 했다가 결과를 호출하나  아래에서 get 을 호출하나 결과는 같다

		int64 sum = future.get();		//get 을 만나면 이때까지 스레드가 실행 안됐다면 대기했다가 결과 반환

 

 

 

 

 

wiat_for  추가 설명


future 는 thread에서 연산된 결과 값을 전달 받기 위해 사용된다.

wait() / get()은 thread에서 return 또한 set_value() 함수가 호출될 때까지 무한정 대기한다. 

wait_for()를 사용하면 일정 시간만큼 thread에서 값이 전달되기 기다리다, timout이 되면 block 상태가 해제된다. 

 

#include <iostream>
#include <future>
#include <thread>
#include <chrono>
 
int main()
{
    std::future<int> future = std::async(std::launch::async, [](){ 
        std::this_thread::sleep_for(std::chrono::seconds(3));
        return 8;  
    }); 
 
    std::cout << "waiting...\n";
    std::future_status status;
    do {
        status = future.wait_for(std::chrono::seconds(1));
        if (status == std::future_status::deferred) {
            std::cout << "deferred\n";
        } else if (status == std::future_status::timeout) {
            std::cout << "timeout\n";
        } else if (status == std::future_status::ready) {
            std::cout << "ready!\n";
        }
    } while (status != std::future_status::ready); 
 
    std::cout << "result is " << future.get() << '\n';
}

 

Line별로 자세히 살펴보도록 하자. 

std::future<int> future = std::async(std::launch::async, [](){ 
        std::this_thread::sleep_for(std::chrono::seconds(3));
        return 8;  
    });

std::launch::async 정책으로 async()를 수행하는데, lamda 표현식을 사용하여 thread를 생성하였다. ( 3초를 sleep하고 8을 return ) 

 

std::future_status status;
    do {
        status = future.wait_for(std::chrono::seconds(1));
        if (status == std::future_status::deferred) {
            std::cout << "deferred\n";
        } else if (status == std::future_status::timeout) {
            std::cout << "timeout\n";
        } else if (status == std::future_status::ready) {
            std::cout << "ready!\n";
        }
    } while (status != std::future_status::ready);

이 섹션에서 핵심 코드라고 할 수 있다. 

wait_for() 함수는 아래와 같은 return 값을 가진다. 

wait_for()에서 second(1)을 전달하여 1초 대기하도록 선언하였다. 앞서 본 thread에서 3초 sleep 후 8을 return 하기에 

do - while 1번째에는 timeout이 발생하게된다. (timeout 로그 출력) 

이후 do - while 조건문에서 status가 ready가 아니므로, 다시 wait_for() 함수가 호출된다. thread는 아직 3초가 지나지 않았기에 다시 한번 timeout이 발생한다. (timeout 로그 출력) 

다시 do - while 3번째에서 wait_for() 함수가 호출되어 1초를 wait하게 되는데, 이제는 thread가 3초 sleep에서 깨어나 8을 return 하게 된다. 

그래서 wait_for() 함수는 ready를 리턴하게 된다. 

do - while 문에서 status가 ready가 되어 do - while문을 빠져 나와 get() 함수를 통해 8을 출력하게 된다. 


클래스 인스턴스와 멤버 함수 호출 시

 		class ABC
		{
		public :
			int64 getalph() {
				return 10;
			}
		};

		ABC abc;

		std::future<int64> future2 = std::async(std::launch::async, &ABC::getalph, abc);

		auto result = future2.get();

결과는 10

 

 

 

std::promise 와 std::future 조합

 

스레드 만들고 전역변수 만들어 데이터 전달하기 보단 스레드 함수에서 데이터를

assign 하여 스래드 대기하는 쪽에서 데이터를 받아 볼 수 있는 기법

 

 

void promiseWorker(std::promise<string>&& promise)
{
	promise.set_value("secret message");
}

main 함수 중....
{
			//아래 코드처럼 작성하면
			//promise 에서 future 를 얻어올 수 있음
			//만약 promise 에 어떤 데이트를 세팅하면
			//future 에서 데이터를 얻어올 수 있음
			//미래에 결과물을 반환해달라는 일종의 약속(promise) 하는 것

			std::promise<string> promise;
			std::future<string> future = promise.get_future();
			//여기 까지 설정한다음
			
			//스레드를 만들어 소유권을 넘겨주면
			//즉 std::move(promise) 를 promiseWorker 스레드에 인자로 넘겨 준다
			thread t(promiseWorker, std::move(promise));

			//그리고 위 코드에서 
			//promise.set_value("secret message");
			//를 했음으로 promise 에서 이 데이터를 다음 처럼 받아 올 수 있다

			string data = future.get();

			std::cout << data;
            t.join();
}

 

promise 에서 future 를 얻어올 수 있음
만약 promise 에 어떤 데이트를 세팅하면
future 에서 데이터를 얻어올 수 있음
미래에 결과물을 반환해달라는 일종의 약속(promise) 하는 것

 

std::promise<string> promise;
std::future<string> future = promise.get_future();

여기 까지 설정한다음

스레드를 만들어 소유권을 넘겨주면
즉 std::move(promise) 를 promiseWorker 스레드에 인자로 넘겨 준다
thread t(promiseWorker, std::move(promise));

 

 

그리고 위 코드에서 
promise.set_value("secret message");
를 했음으로 promise 에서 이 데이터를 다음 처럼 받아 올 수 있다

 

string data = future.get();

std::cout << data;

 

 

결과 화면

 

 

 

 

 

코드에서 보면 move 하기 전에 promise 가 pending 상태지만 move 하고 난 이후엔 main 에서의 promise 는 소유권을 넘겨주기 때문에 empty 가 되어 더이상 지역변수 promise 를 사용하면 안된다 

즉 결과는 fugure.get()  을 통해 넘겨 받게 된다

 

이렇게 하는 이유는 실수로 여러번 get을 호출하면 문제가 되기 때문에

 

async 와 차이점이 있다면 promise 는 객체를 만들어서 여기저기 재사용이 가능하다

 

 

 

 

 

packaged_task 와 future

promise 와 유사하지만 사용이 간단한 packaged_task 가 있음

 

	void taskWorker(std::packaged_task<int64(void)>&& task)
	{
		task();
	}

	//main 어딘가..
	{
		//promise 와 유사하지만 사용이 간단한 packaged_task 가 있음
		std::packaged_task<int64(void)> task(calculate);
		std::future<int64> future = task.get_future();

		thread t(taskWorker, std::move(task));

		std::cout << future.get() << std::endl;;

		t.join();
	}

 

 

결과는 

100000

 

 

promise 와 차이점이 있다면 동일한 함수타입에 대한 함수들에 task를 넘겨줘서 

결과물을 받아올때 유용

 

 

https://answer-me.tistory.com/32

ref : https://docs.microsoft.com/ko-kr/cpp/parallel/amp/reference/completion-future-class?view=msvc-170 

반응형
반응형

 

async 를 실행할대 인자는 대표적으로 두개가 있다

 

Specifies the launch policy for a task executed by the std::async function. 

std::launch is an enumeration used as BitmaskType.

The following constants denoting individual bits are defined by the standard library:

Constant Explanation
std::launch::async the task is executed on a different thread, potentially by creating and launching it first
std::launch::deferred the task is executed on the calling thread the first time its result is requested (lazy evaluation)

 

thread 를 굳이 만들지 않고 lock_guard 나 간편하게 비동기로 처리 하고 싶을 때 사용 할 수 있다

ex) 파일 로딩..

 

  • std::future  쓰레드 만들때 좀 더 간단한 방식으로 만드는 형태, 간단하게 쓰일 때 유용
  • calculate 를 async 방식으로 호출 하는 것

  • 대표적으론 두가지 혼합해서 한가지 총 3가지 방식이 있다
    • std::launch::deffered : 지연된 연산(lazy evaluation)인데 지연해서 실행해라 라는 얘기
      get() 를 만날때 실행된다

    • std::launch::async : 별도의 스레드를 만들어서 실행하라는 것
      실제 스레드 개수가 늘어나는것을 Thread 창에서 확인 할수 있다
      스레드를 별도로 만들지 않아도 내부적으로 알아서 만들어서 비동기로 실행함  ex) 데이터 파일 로딩시..
      get() 함수를 통해 join 같은효과를 낼 수 있으며 결과 값을 반환 받을 수 있다

    • std::launch::deffered | std::launch::async : 둘 중에 아무거나 골라서 실행 하라는 것 



 

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>	
#include <mutex>
#include "AccountManager.h"
#include "UserManager.h"
#include <chrono>
#include <future>
#include "windows.h"

using namespace std;

int64 calculate()
{
	int64 sum = 0;
	for (int32 i = 0; i < 100'000; ++i)
	{
		sum += 1;
	}
	return sum;
}


int main()
{
	{
		//std::future  쓰레드 만들때 좀 더 간단한 방식으로 만드는 형태, 간단하게 쓰일 때 유용
		//calculate 를 async 방식으로 호출 하는 것
		/*
		* std::launch::deffered : 지연된 연산(lazy evaluation)인데 지연해서 실행해라 라는 얘기
		  std::launch::async : 별도의 스레드를 만들어서 실행하라는 것
		  std::launch::deffered | std::launch::async : 둘 중에 아무거나 골라서 실행 하라는 것 
		*/
		//실제 스레드 개수가 늘어나는것을 Thread 창에서 확인 할수 있다
		//스레드를 별도로 만들지 않아도 내부적으로 알아서 만들어서 비동기로 실행함  ex) 데이터 파일 로딩시..
		std::future<int64> future = std::async(std::launch::async, calculate);		//비 동기 호출, calculate 가 호출 됨

		//이후에 다른 코드를 작성하다가 
		//시간이 지나서 해당 결과를 필요할대 꺼내 올 수 있다
		int64 sum = future.get();		//get 을 만나면 이때까지 스레드가 실행 안됐다면 대기했다가 결과 반환



		std::future<int64> futureDef = std::async(std::launch::deferred, calculate);		//이건 스레드가 아니고 나중에 호출 하겠다는 얘기임

		//실제 스레드 개수가 늘어나지 않는 다는 것을 Thread 창에서 확인 할 수 있다
		int64 sum2 = futureDef.get();		//이때 실행 됨

	}

	return 0;
}

 

 

 

ref : https://en.cppreference.com/w/cpp/thread/launch

반응형
반응형

condition_variable  은 전통적인 CreateEvent, SetEvet 를 대체하는 방식으로 볼 수 있다

condition_variable 은 멀티 플랫폼에서도 작동하는 표준으로 들어가 있다

 

condition_variable 은 커널오브젝트인 event 와 유사한것 같지만
유저모드 레벨 오브젝라는 점이 다르다

즉, 동일한 프로그램 내에서만 사용 가능하다

표준 mutex 와 짝지어 사용 가능하다
condition_variable cv;

 

 

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>	
#include <atomic>			//멀티 플랫폼에서 작동 가능
#include <vector>
#include <mutex>
#include "AccountManager.h"
#include "UserManager.h"
#include <chrono>
#include <queue>
#include "windows.h"

using namespace std;

mutex m;
queue<int32> q;
//HANDLE handle;

//condition_variable 은 커널오브젝트인 event 와 유사한것 같지만
//유저모드 레벨 오브젝라는 점이 다르다 즉, 동일한 프로그램 내에서만 사용 가능하다
//표준 mutex 와 짝지어 사용 가능하다
condition_variable cv;			



void producer()
{
	while (true)
	{

		// 1) lock 을 잡고
		//2 공유변수 값 수정 q
		//3 lock 을 풀고
		//4 조건 변수 통해 다른 스레드에게 통지 (condition variable 로 통지
		{
			unique_lock<mutex> lock(m);		//1. lock 을 잡는다
			q.push(100);								//2. 공유 값 수정
		}												//3. lock 을 풀어줌
		
		//::SetEvent(handle);		//event 를 signal 상태로 만든다
		cv.notify_one();				//SetEvent 대신 notify 를 해줘서 wait 중인 스레드들 중 딱 1개를 깨운다

		//this_thread::sleep_for(100ms);
	}
}


//q 에 데이터가 있을 때에만 pop 하여 꺼내서 출력하고 q 에 데이터가 없다면 대기하게 된다
void consumer()
{
	while (true)
	{
		//::WaitForSingleObject(handle, INFINITE);					//SetEvent 사용시 wait for 이함수로 대기했지만...
		//std::cout << "ConSumer 함수" << std::endl;
		{
			unique_lock<mutex> lock(m);								//우선 lock 을 해서 스레드를 한번 block 해줌 (push 나 pop 중 하나만 일어나도록 하기 위함)
			cv.wait(lock, []() {  return q.empty() == false;  });		//조건을 넣어주는데, 조건이 참일 때까지 기다리게 한다

			//q가 비어 있으면 결과는 (true == false) => false  인데 wait(false) 로 들어오면 lock 풀고 대기 한다, 즉 스레드가 중지된다
            //이렇게 lock 을 풀어줘야 하는 상황이 발생 할 수 있기 때문에 unique_lock 을 써야만 한다, conditional_variable 을 사용할때는
			//q가 비어 있지 않으면 결과는 (false == false) => true wait(true) 로 들어오면 빠져나와서 다음 코드를 진행한다

			//wait 에 false 가 들어가면 lock풀고 대기
			//wait 에 true 가 들어가면 다음 코드 진행

			//q가 비어 있다면 => (true==false) => false , wait(false) =>결과적으로 lock 풀고 대기 한다
			//q가 차 있다면 => (false==false) => true , wait(true) => 결과적으로 다음 실행해서 데이터 꺼내서 출력


			//조건이 맞으면 다음으로 넘어가고 그렇지 않으면 lock을 풀어주고 대기 상태로 전환된다

			//lock 을 또 다시 wait 하는 이유는 Spurious wakeup(가짜 기상) 때문인데
			//unique_lock<mutex> lock(m); 해서 대기하고 있는 상황에서 다른 스레드에서 notify_one를해서 쓰레드를 깨웠는데
			//unique_lock<mutex> lock(m); 과     cv.wait(lock, []() {  return q.empty() == false;  }); 사이에서 다른곳에서 lock 을 할 수도 있기 때문에
			//wait 에서 lock 을 한번더 확인해 주는것인데 그 사이에 다른 스레드가 lock 해버리는 상황을 Spurious wakeup 이라 한다

			
			//condition variable 에서 wait 하는 순간 발생 하는 것들
			//1 : lock 을 잡고 ( 만약 wait 위에서 lock 을 잡았다면 wait 에서 다시 lock 을 잡진 않는다
			//2 : 조건 확인
			//3-1 : 조건 만족 하면 빠젼 나와서 이어서 코드를 진행한다
			//3-2 : 조건 만족하지 않으면 lock 을 풀어주고 대기 상태로 전환한다	(=> 경우에 따라서 constant variable 이 unlock 을 해야 하기 대문에 unique_lock 을 사용해야만 함)
					//3-2-sub : 3-2 에서 대기 하고 있다가 다른곳에서 notify 를 호출해주면 그때 다시 1번 부터 확인하여 실행 여부가 결정된다

			if (q.empty() == false)
			{
				int32 data = q.front();
				q.pop();
				cout << data <<"\t"<< q.size() << std::endl;
			}
		}

		this_thread::sleep_for(100ms);
	}
}

int main()
{

	//커널오브젝트
	//Usage Count

	//이벤트 속성 : NULL
	//auto rest 방식 으로 지정
	//초기 값
	//이름은 null
	//Event 는 유저모드가 아닌 커널모드에서 관리 되는것이기 떄문에 커널단에서 처리된다
	//handle = ::CreateEvent(nullptr, true, false, nullptr);

	thread t1(producer);
	thread t2(consumer);

	t1.join();
	t2.join();


	//::CloseHandle(handle);

	return 0;
}

 

 

 

produce 에선 (데이터를 추가해주는 쪽)에선 비교적 간단하게 다음 처럼 처리해 준다

 

Conditional_variable 의 처리 순서

1) lock 을 잡고
2 공유변수 값 수정 q
3 lock 을 풀고
4 조건 변수 통해 다른 스레드에게 통지 (condition variable 로 통지

 

이런 프로세스로 추가해주고

 Produce  예시를 적용해보자면 이렇게 된다

 

{
  unique_lock<mutex> lock(m); //1. lock 을 잡는다
  q.push(100); //2. 공유 값 수정
} //3. lock 을 풀어줌

 

4. 대기하고 있는 스레드 중에서 스레드들 중 딱 1개만 깨운다
cv.notify_one();  

 

데이터 추가할때 다른 곳에서 쓰는것과 충돌나는 일을 막기 위해 mutex 로 묶어준다

이후 처리가 끝나면 그냥단순 통지만 하고 끝

 

 

 

consumer 에선 다음 처리 처리한다

 

unique_lock<mutex> lock(m); //우선 lock 을 해서 스레드를 한번 block 해줌 (push 나 pop 중 하나만 일어나도록 하기 위함)
cv.wait(lock, []() {  return q.empty() == false;  }); //조건을 넣어주는데, 조건이 참일 때까지 기다리게 한다

 

Condition variable 은 다음 특징이 있다

wait(false) 에 false 가 들어가면 lock풀고 대기
wait(true) 에 true 가 들어가면 다음 코드 진행

q가 비어 있다면 => (true==false) => false , wait(false) =>결과적으로 lock 풀고 대기 한다
q가 차 있다면 => (false==false) => true , wait(true) => 결과적으로 다음 실행해서 데이터 꺼내서 출력

 

 

Spurious wakeup

 

lock 을 또 다시 wait 하는 이유는 Spurious wakeup(가짜 기상) 때문인데
unique_lock<mutex> lock(m); 해서 대기하고 있는 상황에서 다른 스레드에서 notify_one를해서 쓰레드를 깨웠는데
unique_lock<mutex> lock(m); 과     cv.wait(lock, []() {  return q.empty() == false;  }); 사이에서 다른곳에서 lock 을 할 수도 있기 때문에
wait 에서 lock 을 한번더 확인해 주는것(즉 lock 을 한번 더 건다)인데 하지만 그 사이에 다른 스레드가 lock 해버리는 상황이 발생 되면 wait 은 다른곳이 lock 을 했기 때문에 대기 해야 하고 이 것을 Spurious wakeup 즉 가짜 wakeup 이라 한다

 

다시 정리 하자면 이중으로 unique_lock 에 이어 한번더  wait 에서 lock 을 체크 하는데

unique_lock 과 wait 사이에서 다른 스레드가 lock 을 가져 갈수 있기 때문에 방지 차원에서 wati 에서 lock 을

체크하는 로직이 들어가게 된것이다

 

 

 

Spurious wakeup

A spurious wakeup happens when a thread wakes up from waiting on a condition variable that's been signaled, only to discover that the condition it was waiting for isn't satisfied. It's called spurious because the thread has seemingly been awakened for no reason. But spurious wakeups don't happen for no reason: they usually happen because, in between the time when the condition variable was signaled and when the waiting thread finally ran, another thread ran and changed the condition. There was a race condition between the threads, with the typical result that sometimes, the thread waking up on the condition variable runs first, winning the race, and sometimes it runs second, losing the race.

On many systems, especially multiprocessor systems, the problem of spurious wakeups is exacerbated because if there are several threads waiting on the condition variable when it's signaled, the system may decide to wake them all up, treating every signal( ) to wake one thread as a broadcast( ) to wake all of them, thus breaking any possibly expected 1:1 relationship between signals and wakeups.[1] If there are ten threads waiting, only one will win and the other nine will experience spurious wakeups.

To allow for implementation flexibility in dealing with error conditions and races inside the operating system, condition variables may also be allowed to return from a wait even if not signaled, though it is not clear how many implementations actually do that. In the Solaris implementation of condition variables, a spurious wakeup may occur without the condition being signaled if the process is signaled; the wait system call aborts and returns EINTR.[2] The Linux p-thread implementation of condition variables guarantees it will not do that.[3][4]

Because spurious wakeups can happen whenever there's a race and possibly even in the absence of a race or a signal, when a thread wakes on a condition variable, it should always check that the condition it sought is satisfied. If it is not, it should go back to sleeping on the condition variable, waiting for another opportunity.

 

 

가짜 wakeup 로 인해 성능이 악화 되는 상황

가짜 wakeup 의 상황이 되면 현재 돌아가려고 하는 스레드가 우선권을 race 에서 뺐기고 다른 스레드가 계속 가져가게 되서 느려지는데 만약 실행되야 하는 스레드가 있고 다른 스레드 10개가 돌아가고 있다면 이때 레이스를 하면서 실행되야 하는 스레드는 더욱더 지연 될 수 있게 되어 성능이 악화 될 수 있다

 

 

ref : https://en.wikipedia.org/wiki/Spurious_wakeup

반응형
반응형

waitcauses the current thread to block until the condition variable is notified or a spurious wakeup occurs, optionally looping until some predicate is satisfied (bool(stop_waiting()) == true).

 

 

예제

 

#include <iostream>
#include <condition_variable>
#include <thread>
#include <chrono>
 
std::condition_variable cv;
std::mutex cv_m; // This mutex is used for three purposes:
                 // 1) to synchronize accesses to i
                 // 2) to synchronize accesses to std::cerr
                 // 3) for the condition variable cv
int i = 0;
 
void waits()
{
    std::unique_lock<std::mutex> lk(cv_m);
    std::cerr << "Waiting... \n";
    cv.wait(lk, []{return i == 1;});
    std::cerr << "...finished waiting. i == 1\n";
}
 
void signals()
{
    std::this_thread::sleep_for(std::chrono::seconds(1));
    {
        std::lock_guard<std::mutex> lk(cv_m);
        std::cerr << "Notifying...\n";
    }
    cv.notify_all();
 
    std::this_thread::sleep_for(std::chrono::seconds(1));
 
    {
        std::lock_guard<std::mutex> lk(cv_m);
        i = 1;
        std::cerr << "Notifying again...\n";
    }
    cv.notify_all();
}
 
int main()
{
    std::thread t1(waits), t2(waits), t3(waits), t4(signals);
    t1.join(); 
    t2.join(); 
    t3.join();
    t4.join();
}

Possible output:

Waiting...
Waiting...
Waiting...
Notifying...
Notifying again...
...finished waiting. i == 1
...finished waiting. i == 1
...finished waiting. i == 1

 

Conditional Variable 은 

wait 할떄 

wiat( false ) 면 lock 을 풀고 대기 하고

wiat( true ) 면 빠져나와 다음 코드를 진행한다

 

 

 

ref : https://en.cppreference.com/w/cpp/thread/condition_variable/wait

반응형
반응형
  • 프로세스 간의 동기화와 유저모드간의 동기화 모두 가능하다 (유저모드 모단 느리다)
  • spinlock 은 유저레벨에서 동기화 가능하다

 

이벤트에는 두가지가 있다

 

auto reset event

manual reset event

 

unique_lock 이글 참고

https://3dmpengines.tistory.com/2176

 

 

 

한쪽에선 클라이언트 데이터를 수신 받아와서 q에 밀어넣고 
게임 패킷과 관련된 스레드에서 q에서 데이터를 추출해오는 상황이라 가정하여 이를 작성하면 다음 코드 처럼 되고

 

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>	
#include <atomic>			//멀티 플랫폼에서 작동 가능
#include <vector>
#include <mutex>
#include "AccountManager.h"
#include "UserManager.h"
#include <chrono>
#include <queue>
#include "windows.h"

using namespace std;

mutex m;
queue<int32> q;

//한쪽에선 클라이언트 데이터를 수신 받아와서 q에 밀어넣고 
//게임 패킷과 관련된 스레드에서 q에서 데이터를 추출해오는 상황이라 가정

void producer()
{
	while (true)
	{
		{
			unique_lock<mutex> lock(m);
			q.push(100);
		}
		
		this_thread::sleep_for(100ms);
	}
}

void consumer()
{
	while (true)
	{
		{
			unique_lock<mutex> lock(m);
			if (q.empty() == false)
			{
				int32 data = q.front();
				q.pop();
				cout << data << std::endl;
			}
		}

		this_thread::sleep_for(100ms);
	}
}

int main()
{

	thread t1(producer);
	thread t2(consumer);

	t1.join();
	t2.join();


	return 0;
}

 

결과는 100ms 마다 q 에 데이터를 push 하고 consumer 함수에 데이터를 꺼내다 쓰는 상황이 된다

 

 

 

하지만 만야겡 입력이 엄청 긴시간 동일 일어나지 않고 어쩌다 한번씩 입력이 일어난다고 하면

consumer 스레드는 계속 lock 과 unlock 을 반복하고 있긴 하기 때문에 이 부분에 스레드가 차이하는 비용이 그냥 이 코드만 보면 높진 않지만 이런게 많이 쌓이면 성능에 좋지 않음으로 이 부분을 event 를 고려하여 처리 하면

더 효율적으로 작업 할 수 있다

 

현재는 테스트 pc 가 좋아서 이후 테스트 결과와 크게 차이가 없는 cpu 점유율 0 % 인데

성능이 낮은 PC 일 수록 이것이 8~10% 까지도 올라 갈 순 있다

 

 

처리 방식 : 데이터가 q 에 있는 상황 즉 consume 해도 되는 상황에서 consumer 스레드가 돌도록 한다

 

 

 

Event 는 유저모드가 아닌 커널모드에서 관리 되는것이기 떄문에 커널단에서 처리된다

 

//이벤트 속성 : NULL
//auto rest 방식 으로 지정
//초기 값
//이름은 null
//Event 는 유저모드가 아닌 커널모드에서 관리 되는것이기 떄문에 커널단에서 처리된다

handle = ::CreateEvent(nullptr, false, false, nullptr);  //non-signal 상태

 

handle 이 물고 있는건 커널오브젝트인데 커널에서 관리하는 이벤트 오브젝트라 보면 된다

 

커널오브젝트에는 Usage Count = 이 오브젝트를 몇명이 사용하고 있는지 

그리고 Signal / Non-Signal 두가지 중 하나의 상태를 갖고 있다 (bool)

true 명 Signal 상태

자동모드/수동모드 에 대한 정보(이벤트는 자동모드 수동모드가 있다)

(자동모드 : 이벤트를 바로 리셋 해주는 모드)

 

SetEvent(handle) 하면 Signla 상태가 된다

 

 

 

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>	
#include <atomic>			//멀티 플랫폼에서 작동 가능
#include <vector>
#include <mutex>
#include "AccountManager.h"
#include "UserManager.h"
#include <chrono>
#include <queue>
#include "windows.h"

using namespace std;

mutex m;
queue<int32> q;
HANDLE handle;

//한쪽에선 클라이언트 데이터를 수신 받아와서 q에 밀어넣고 
//게임 패킷과 관련된 스레드에서 q에서 데이터를 추출해오는 상황이라 가정

void producer()
{
	while (true)
	{
		{
			unique_lock<mutex> lock(m);
			q.push(100);
		}
		
		::SetEvent(handle);		//event 를 signal 상태로 만든다

		this_thread::sleep_for(100ms);
	}
}

void consumer()
{
	while (true)
	{
		//무한 대기, handle 가 Signal 상태가 될때까지
		::WaitForSingleObject(handle, INFINITE);					//evet 가 signal 상태가 되면 빠져나옴 이 함수를
		//즉 WaitForSingleObject 를 만나면 이 쓰레드는 wake up 되지 않고 즉 실행 되지 않고 수면 상태(sleep)로 빠져서 잠들게 된다
		//WaitForSingleObject 에 걸린 이벤트는 handle 자동모드라 WaitForSingleObject 이 함수가 실행된고 빠져나오는 즉시 event 가 non-signal 이 된다

		std::cout << "ConSumer 함수" << std::endl;
		{
			unique_lock<mutex> lock(m);
			if (q.empty() == false)
			{
				int32 data = q.front();
				q.pop();
				cout << data << std::endl;
			}
		}

		this_thread::sleep_for(100ms);
	}
}

int main()
{

	//커널오브젝트
	//Usage Count

	//이벤트 속성 : NULL
	//auto rest 방식 으로 지정
	//초기 값
	//이름은 null
	//Event 는 유저모드가 아닌 커널모드에서 관리 되는것이기 떄문에 커널단에서 처리된다
	handle = ::CreateEvent(nullptr, false, false, nullptr);

	thread t1(producer);
	thread t2(consumer);

	t1.join();
	t2.join();


	::CloseHandle(handle);

	return 0;
}

 

무한 대기, handle 가 Signal 상태가 될때까지
::WaitForSingleObject(handle, INFINITE); //evet 가 signal 상태가 되면 빠져나옴 이 함수를
즉 WaitForSingleObject 를 만나면 이 쓰레드는 wake up 되지 않고 즉 실행 되지 않고 수면 상태(sleep)로 빠져서 잠들게 된다, 즉 필요 없는 스레드가 돌아가지 않게 효율을 올릴 수 있다


WaitForSingleObject 에 걸린 이벤트는 handle 자동모드라 WaitForSingleObject 이 함수가 실행된고 빠져나오는 즉시 event 가 non-signal 이 된다

 

 

 

결과 또한 데이터가 있을때 Consumer 를 통해 소진 되는 것을 얼추 알 수 있다

즉 데이터가 있을때 Comsumer 함수가 실행 되도록 evet 로 실행 순서를 제어한 것

 

이렇게 처리 하면 CPU 점유율이 0 % 대로 떨어지는 것을 알 수 있다

 

 

 

결과는 같고 아래는 Event 가  수동모드이다

wait.. 을 빠져나온 다음 바로 ResetEvent(handle); 을 처리 해주면 자동모드와 동일하다

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>	
#include <atomic>			//멀티 플랫폼에서 작동 가능
#include <vector>
#include <mutex>
#include "AccountManager.h"
#include "UserManager.h"
#include <chrono>
#include <queue>
#include "windows.h"

using namespace std;

mutex m;
queue<int32> q;
HANDLE handle;

//한쪽에선 클라이언트 데이터를 수신 받아와서 q에 밀어넣고 
//게임 패킷과 관련된 스레드에서 q에서 데이터를 추출해오는 상황이라 가정

void producer()
{
	while (true)
	{
		{
			unique_lock<mutex> lock(m);
			q.push(100);
		}
		
		::SetEvent(handle);		//event 를 signal 상태로 만든다

		this_thread::sleep_for(100ms);
	}
}

void consumer()
{
	while (true)
	{
		//무한 대기, handle 가 Signal 상태가 될때까지
		::WaitForSingleObject(handle, INFINITE);
		::ResetEvent(handle);
		
	

		std::cout << "ConSumer 함수" << std::endl;
		{
			unique_lock<mutex> lock(m);
			if (q.empty() == false)
			{
				int32 data = q.front();
				q.pop();
				cout << data << std::endl;
			}
		}

		this_thread::sleep_for(100ms);
	}
}

int main()
{

	//커널오브젝트
	//Usage Count

	//이벤트 속성 : NULL
	//auto rest 방식 으로 지정
	//초기 값
	//이름은 null
	//Event 는 유저모드가 아닌 커널모드에서 관리 되는것이기 떄문에 커널단에서 처리된다
	handle = ::CreateEvent(nullptr, true, false, nullptr);

	thread t1(producer);
	thread t2(consumer);

	t1.join();
	t2.join();


	::CloseHandle(handle);

	return 0;
}

 

 

 

반응형
반응형

 

system call : 자발적으로 현재 스래드가 실행할 필요 없다고 커널(os) 에게 알려주는 것

cout 같은걸 행했다면 커널모드에 요청을해야 처리가 가능해서 

이때 system call 을 요청하면 커널모드로 들어가게 되고 요청 받은걸 실행한 다음 다시 스레드를 재생하는 형태로 실행된다

 

 

스케줄러 그림인데

스레드 단위로 실행 될때 실행상태에서 자신이 다 실행했다고 하면 context switch 를 통해서 준비 단계로 이동 할 수 있다

그럼 나중에 cpu 가 스케줄러를 보고서 실행할 스레드를 결정하여 실행 처리를 한다

 

cout 은 자발적 문맥교환 이라 볼 수 있다

 

 

 

 

Sleep 함수 

 

유저 모드와 커널모드를 왔다갔다 할때

 

유저모드의 스레드들은 시간을 분할 받아 실행을 일정 주기 마다 하게 되는데 

 

sleep 함수는 커널모드에서 스케줄러에 의해 일정 시간 대기했다가 다시 실행시키는 함수이다

 

Sleep (system call)

From Wikipedia, the free encyclopedia
A computer program (process, task, or thread) may sleep, which places it into an inactive state for a period of time. Eventually the expiration of an interval timer, or the receipt of a signal or interrupt causes the program to resume execution.

 

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>	
#include <atomic>			//멀티 플랫폼에서 작동 가능
#include <vector>
#include <mutex>
#include "AccountManager.h"
#include "UserManager.h"
#include <chrono>

using namespace std;

mutex m1;
int32 sum = 0;

class SpinLock
{
public:
	void lock() 
	{
		bool expected = false;
		bool desired = true;

		//_locked 값이 기대 하는 값과 같으면 계속 무한 루프를 돌면서 spinlock 으로 계속 대기 하고, 그렇지 않고 expected 값이 locked 다르면
		//내부적으로 _locked 값을 desired 값으로 바꾼다음 while 을 탈출해 spinlock 에서 빠져나오게 한다
		while (_locked.compare_exchange_strong(expected, desired) == false)
		{
			
			//expected = false; 이렇게 설정해주면 locked 를 얻을 때까지 무한 대기 하게 되는데 cpu 를 사용하면서,
			expected = false;		//compare_exchange_strong 내부에서 expected 값을 & 로 바꾸기 때문에 원래 초기 상태로 바꾼다
			this_thread::sleep_for(100ms);		//100 ms 동안 sleep 하도록 함 => 이렇게 하면 cpu 파워를 낭비 하지 않고 context switch 된 이후에 일정 시간이후에 다시 와서 locked 를 획을 할 수 있을지 확인하게 되어
			//무한 cpu 자원을 사용하는건 막을 수 있지만 , 다시 돌아왔을때 이미 다른 스레드에서 lock 을 얻어 갔다면 일정 시간이후에 다시 돌아온 스레드는 또다시 wait 해야 하는상황이 발생되게 되는 악순환이 될 수도 있다
			//this_thread::yield();	// ==this_thread::sleep_for(0ms);  이것과 동일하다
			//이렇게 되면 엄밀히 말하면 spinlock 이 되는것은 아니다
			
		}
	}

	void unlock()
	{
		_locked.store(false);	//bool 을 바꾸는 것도 또한 원자적으로 일어나야함 
	}
	volatile atomic<bool>  _locked = false;	//bool 값도 그냥 바꾸면 명령어가 두개로 처리됨
};

SpinLock spinLock;

void func1()
{
	for (int32 i=0;i<10000;++i)
	{
		lock_guard<SpinLock> guard(spinLock);
		sum++;
	}
}

void func2()
{
	for (int32 i = 0; i < 10000; ++i)
	{
		lock_guard<SpinLock> guard(spinLock);
		sum--;
	}
}

int main()
{

	thread t1(func1);
	thread t2(func2);

	t1.join();
	t2.join();

	std::cout << sum << std::endl;

	return 0;
}

 

while (_locked.compare_exchange_strong(expected, desired) == false)
{

     //expected = false; 이렇게 설정해주면 locked 를 얻을 때까지 무한 대기 하게 되는데 cpu 를 사용하면서,
     expected = false; //compare_exchange_strong 내부에서 expected 값을 & 로 바꾸기 때문에 원래 초기 상태로 바꾼다

 

this_thread::sleep_for(100ms);    100 ms 동안 sleep 하도록 함

 

이렇게 하면 cpu 파워를 낭비 하지 않고 context switch 된 이후에 일정 시간이후에 다시 와서

 

locked 를 획을 할 수 있을지 확인하게 되어
무한 cpu 자원을 사용하는건 막을 수 있지만 , 다시 돌아왔을때 이미 다른 스레드에서 lock 을 얻어 갔다면 일정 시간이후에 다시 돌아온 스레드는 또다시 wait 해야 하는상황이 발생되게 되는 악순환이 될 수도 있다

 

this_thread::yield();

위으 것과 this_thread::sleep_for(0ms);  이것과 동일하다

 

이렇게 되면 엄밀히 말하면 spinlock 이 되는것은 아니다

 

 

여기에서 포인트는 커널모드로 들어가서 Context Switch 를 유발하고 
유저모드에서 커널모드로 들어가게 할수 있다는 것임 

그리고 cout 같은 시스템 콜이 있다면 위와 같은 이유로 느려지게 된다는 것이다 

 

핵심은 system call 을 필요 없이 요청하는건 자제해야 한다는것!

 

 

 

 

 

ref : https://en.wikipedia.org/wiki/Sleep_(system_call)

 

 

반응형
반응형

spin lock 무한 루프를 돌면서 lock 이 풀릴때까지 계속 대기 하는것(Busy wait)

context swith 가 자주 되지 않는 다는 장점이 있지만 오랬 동안 걸릴것 같다면 그 사이 아무것도 못함으로 그 기다리는 동안 부하가 생기게 된다

바쁘게 기다린다는 것은 무한 루프를 돌면서 최대한 다른 스레드에게 CPU 양보하지 않는 것이다.

 

 

Spin Lock  이름이 뜻하는대로만약 다른 스레드가 lock 소유하고 있다면  lock 반환될 때까지 계속 확인하며 기다리는 것이다. "조금만 기다리면 바로   있는데 굳이 컨텍스트 스위칭으로 부하를  필요가 있나?" 라는 컨셉으로 개발된 것으로 크리티컬 섹션에 진입이 불가능할때 컨텍스트 스위칭을 하지 않고 잠시 루프를 돌면서 재시도 하는 것을 말합니다. Lock-Unlcok 과정이 아주 짧아서 락하는 경우가 드문 경우(적절하게 크리티컬 섹션을 사용한 경우유용하다Spin Lock  다음과 같은 특성을 갖는다.

  1. Lock 얻을  없다면계속해서 Lock 확인하며 얻을 때까지 기다린다이른바 바쁘게 기다리는 busy wating이다.
  2. 바쁘게 기다린다는 것은 무한 루프를 돌면서 최대한 다른 스레드에게 CPU 양보하지 않는 것이다.
  3. Lock  사용가능해질 경우 컨택스트 스위치를 줄여 CPU 부담을 덜어준다하지만만약 어떤 스레드가 Lock 오랫동안 유지한다면 오히려 CPU 시간을 많이 소모할 가능성이 있다.
  4.  하나의 CPU 하나의 코어만 있는 경우에는 유용하지 않다 이유는 만약 다른 스레드가 Lock 가지고 있고  스레드가 Lock 풀어 주려면 싱글 CPU 시스템에서는 어차피 컨택스트 스위치가 일어나야 하기 때문이다. 주의할  스핀락을 잘못 사용하면 CPU 사용률 100% 만드는 상황이 발생하므로 주의 해야 한다스핀락은 기본적으로 무한 for 루프를 돌면서 lock 기다리므로 하나의 쓰레드가 lock 오랫동안 가지고 있다면다른 blocking 쓰레드는 busy waiting 하므로 CPU 쓸데없이 낭비하게 된다.

장점은 스핀락을  사용하면 context switch 줄여 효율을 높일  있습니다. 무한 루프를 돌기 보다는 일정 시간 lock 얻을  없다면 잠시 sleep하는 back off 알고리즘을 사용하는 것이 훨씬 좋습니다.

 

 

 

 

스핀 락(Spin lock)과 뮤텍스(Mutex)의 차이

둘 모두 자원에 대해 락을 걸고 사용하려고 할 시에 락이 풀릴 때까지 기다려야 한다는 점은 같지만, 둘은 내부적으로 로우레벨에서 차이점이 있다.

 

 우선 뮤텍스의 경우, 자원에 이미 락이 걸려 있을 경우 락이 풀릴 때까지 기다리며 컨텍스트 스위칭을 실행한다. 

즉, 다른 병렬적인 태스크를 처리하기 위해 CPU를 양보할 수 있다는 것이며 이는 자원을 얻기 위해 오랜 시간을 기다려야 할 것이 예상될 때 다른 작업을 동시에 진행할 수 있다는 것이다. 하지만 이는 자원이 단시간 내로 얻을 수 있게 될 경우 컨텍스트 스위칭에 더 큰 자원을 낭비하게 될 수 있다는 문제가 있다.

 스핀 락의 경우에는 이름에서부터 알 수 있듯이, 자원에 락이 걸려 있을 경우 이를 얻을 때까지 무한 루프를 돌면서 다른 태스크에 CPU를 양보하지 않는 것이다. 자원이 단시간 내로 얻을 수 있게 된다면 컨텍스트 스위칭 비용이 들지 않으므로 효율을 높일 수 있지만, 그 반대의 경우 다른 태스크에 CPU를 양보하지 않으므로 오히려 CPU 효율을 떨어뜨릴 수 있는 문제가 있다.

 

 

 

 

 

 

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>	
#include <atomic>			
#include <vector>
#include <mutex>
#include "AccountManager.h"
#include "UserManager.h"

using namespace std;

mutex m1;
int32 sum = 0;


void func1()
{
	for (int32 i=0;i<10000;++i)
	{
		lock_guard<mutex> guard(m1);
		sum++;
	}
}


void func2()
{
	for (int32 i = 0; i < 10000; ++i)
	{
		lock_guard<mutex> guard(m1);
		sum--;
	}
}

int main()
{

	thread t1(func1);
	thread t2(func2);

	t1.join();
	t2.join();

	std::cout << sum << std::endl;


	return 0;
}

 

 

위 코드 결과는 0 이며 mutex 를 활용한 것이다 정상적으로처리 된다

 

하지만 아래처럼 custom 한 lock 클래스를 구현하여 처리 할려고 보면 아래와 같이 정사적인 연산이 되지 않는다는 것을 알 수 있다

 

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>	
#include <atomic>			//멀티 플랫폼에서 작동 가능
#include <vector>
#include <mutex>
#include "AccountManager.h"
#include "UserManager.h"

using namespace std;

mutex m1;
int32 sum = 0;

class SpinLock
{
public:
	void lock() 
	{
		while (_locked)
		{
		}
		_locked = true;
	}

	void unlock()
	{
		_locked = false;
	}
	bool _locked = false;

};


SpinLock spinLock;


void func1()
{
	for (int32 i=0;i<10000;++i)
	{
		lock_guard<SpinLock> guard(spinLock);
		sum++;
	}
}


void func2()
{
	for (int32 i = 0; i < 10000; ++i)
	{
		lock_guard<SpinLock> guard(spinLock);
		sum--;
	}
}

int main()
{

	thread t1(func1);
	thread t2(func2);

	t1.join();
	t2.join();

	std::cout << sum << std::endl;


	return 0;
}

 

결과 -5512  등등 이상한 값이 나옴..

 

 

volatile 는 해당 코드를 최적화 하지 말라고 하는것 반복문 등에서.. 하지만 이게 지금의문제는 아니지만 참고사항으로

 

_locked = true; 이 코드는 한줄이 아님

_locked = true;
00007FF60EC7405F  mov         rax,qword ptr [this]  
00007FF60EC74066  mov         byte ptr [rax],1

bool 값도 원자적으로 일어나야지 문제가 발생하지 않음으로 atomic 으로 처리해야한다

또한 lock_guard 에서 lock 함수와 unlock 함수를 사용하는 이유에서라도 atomic 을 사용해야한다

 

 

 

문제는 아래 명령이 하나로 처리 되야 하는데 로직이 두개가 보니 즉 wait 하다가 값이 바뀌면 바로 빠져나가야 하는데 스레드가 전환 되면서 값이 제대로 바뀌지 않을 수 있기 때문에 문제가 발생하기 때문에

while (_locked)
{
}
_locked = true;

 

이게 한번의 명령으로 처리 되지 않아 context switch 가 되면서 상태가 망가지는 것

while 쪽의 조건과 _locked 값을 한번에 처리 하는 방식으로 대기하도록 바꿔줘야 할 필요가 있다

즉 한방에 처리해야 한다는 것

 

 

이것을 한방에 수행하도록 묶어주는 방법이 있음 

CAS (Compare-And-Swap)  : OS 에 따라서 InterlockedExchagned(..) 같은 함수인데

atomic 을 사용하면 여기에 이것이 포함되어 있음 

 

atomic.compare_exchange_strong 함수임

 

 

 compare_exchange_strong : 이 함수는 atomic 하게 일어나며 (원자적으로 한방에처리 all or nothing) 다음과 유사하다 

 

요약 하자면 expected 와 _locked 값이 같으면 desired 으로 _locked 값을 바꾸고, 그렇지 않다면 _locked 값은 그대로 유지된다
  

 

compare_exchange_strong  : 함수에 대한 설명 결국 값을 바꾸는 것이긴 한데 원하는 값으로 바꾸느냐임


if(_locked == expected )   //만약 _locked 가 기대되는 값이 false 와 같다면
{
    expected = _locked; //_lock 값을 기대되는 값에 넣어준다
    _locked = desired; //그 다음 _lock 값을 원하는 값으로 넣어준다
    return true; //즉 locked 가 기대하는 값과 같다면 _locked 를 원하는 값으로 변경한다
}
else    // _locked 가 기대하는 값과 다르다면
{
      expected = _locked; //기대 값을 _locked값으로 바꾼다
      return false;
}



 

	void lock() 
	{
		bool expected = false;
		bool desired = true;

		/*
		* compare_exchange_strong : 이 함수는 atomic 하게 일어나며
         (원자적으로 한방에처리 all or nothing) 다음과 유사하다 
		* 요약 하자면 expected 와 _locked 값이 같으면 desired 으로 _locked 값을 바꾸고, 
          그렇지 않다면 _locked 값은 그대로 유지된다
		* 
			if(_locked == expected )  	//만약 _locked 가 기대되는 값이 false 와 같다면
			{
				expected = _locked;		//_lock 값을 기대되는 값에 넣어준다
				_locked = desired;		//그 다음 _lock 값을 원하는 값으로 넣어준다
				return true;			//즉 locked 가 기대하는 값과 같다면 
                                        //_locked 를 원하는 값으로 변경한다
			}
			else	// _locked 가 기대하는 값과 다르다면
			{
				expected = _locked;		//기대 값을 _locked값으로 바꾼다
				return false;
			}
		*/
		
		//_locked 값이 기대 하는 값과 같으면 계속 무한 루프를 돌면서 spinlock 으로 계속 대기
        //하고, 그렇지 않고 expected 값이 locked 다르면 내부적으로 _locked 값을 desired 값으로
        //바꾼다음 while 을 탈출해 spinlock 에서 
        //빠져나오게 한다
        
		while (_locked.compare_exchange_strong(expected, desired) == false)
		{
        	//compare_exchange_strong 내부에서 expected 값을 & 로 바꾸기 때문에 
        	//원래 초기 상태로 바꾼다
			expected = false;			
            
		}
	}

	void unlock()
	{
		_locked.store(false);	//bool 을 바꾸는 것도 또한 원자적으로 일어나야함 
	}

 

 

 

 

 

Custom class 로 구혆나 Spinlock 클래스 

 

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>	
#include <atomic>			//멀티 플랫폼에서 작동 가능
#include <vector>
#include <mutex>
#include "AccountManager.h"
#include "UserManager.h"

using namespace std;

mutex m1;
int32 sum = 0;

class SpinLock
{
public:
	void lock() 
	{
		bool expected = false;
		bool desired = true;

		/*
		* compare_exchange_strong : 이 함수는 atomic 하게 일어나며(원자적으로 한방에처리 all or nothing) 다음과 유사하다 
		* 요약 하자면 expected 와 _locked 값이 같으면 desired 으로 _locked 값을 바꾸고 , 그렇지 않다면 _locked 값은 그대로 유지된다
		* 
			if(_locked == expected )  //만약 _locked 가 기대되는 값이 false 와 같다면
			{
				expected = _locked;	//_lock 값을 기대되는 값에 넣어준다
				_locked = desired;		//그 다음 _lock 값을 원하는 값으로 넣어준다
				return true;				//즉 locked 가 기대하는 값과 같다면 _locked 를 원하는 값으로 변경한다
			}
			else	// _locked 가 기대하는 값과 다르다면
			{
				expected = _locked;		//기대 값을 _locked값으로 바꾼다
				return false;
			}
		*/
		
		//_locked 값이 기대 하는 값과 같으면 계속 무한 루프를 돌면서 spinlock 으로 계속 대기 하고, 그렇지 않고 expected 값이 locked 다르면
		//내부적으로 _locked 값을 desired 값으로 바꾼다음 while 을 탈출해 spinlock 에서 빠져나오게 한다
		while (_locked.compare_exchange_strong(expected, desired) == false)
		{
			expected = false;		//compare_exchange_strong 내부에서 expected 값을 & 로 바꾸기 때문에 원래 초기 상태로 바꾼다
		}
		
	}

	void unlock()
	{
		_locked.store(false);	//bool 을 바꾸는 것도 또한 원자적으로 일어나야함 
	}
	volatile atomic<bool>  _locked = false;	//bool 값도 그냥 바꾸면 명령어가 두개로 처리됨

};


SpinLock spinLock;


void func1()
{
	for (int32 i=0;i<10000;++i)
	{
		lock_guard<SpinLock> guard(spinLock);
		sum++;
	}
}


void func2()
{
	for (int32 i = 0; i < 10000; ++i)
	{
		lock_guard<SpinLock> guard(spinLock);
		sum--;
	}
}

int main()
{

	thread t1(func1);
	thread t2(func2);

	t1.join();
	t2.join();

	std::cout << sum << std::endl;


	return 0;
}

 

 

spinlock 을 cpu 를 계속 먹는 상태가 된다 context switch 가 되지 않음으로

그래서 spinlock 끼리 서로 병합을 벌이게 되면 cpu 점유율이 확 올라가게 된다

SpinLock 은 커널레벨이 아닌 유저레벨에서 처리 되는 것임

context switch 가 되지 않아 점유율이 올라감으로 

 

 

 

 

 

반응형
반응형


mutex m1;
mutex m2;
lock(m1, m2);

 

lock_guard<mutex> g1(m1, adopt_lock);     //adopt_lock 이미 lock 되어 있으니 (위에서 lock() ) 끝날떄 풀어주기만 하라는 명령

 

lock_guard<mutex> g2(m2, adopt_lock);  

 

반응형
반응형

방법.1

ProcessSave 에서 lock_guard 를 getAccount 함수와 실행 순서를 바꿔주어 DeadLock 현상을 피하게 한다

 

 

 

요약 : 

  1. t1 스레드가 getaccount 작업을 마칠때까지 t2 스레드는 아무것도 하지 않고 대기만 하다가 t1 작업이 끝나면 t2 가 작업 되게 한다

  2. t2 스레드가 getUser 함수 작업이 완료 될되고 processLogin  함수가 완료 될떄까지 t1 을대기시키고 완료 되면 t1 이 실행되게 허용한다

  3. t2 스레드가 실행되고 processLogin 에서 AccountManager::_mutex 를 lock 하고 getuser 작업을 하려는 직전에 (  UserManager::_mutex lock 못함) t1 스레드가 먼저 실행되어 getAccount가 실행 되는 경우 AccountManager 의 _Mutex 는 이미 lock 되어 있어서 t1 은대기 하게 되고 다시 t2가 실행 되어 UserManager::_mutex 를 lock 한다음 getUser 처리하고 processLogin 함수가 완료가 된 이후에야 AccountManager::_mutex가 unlock 이 되기 때문에 이때서야 t1 스레드의 getAccount 가 호출이 되고 processSave 함수또한 마무리 된다

  4. ProcessSave 함수에서  UserManager::_mutex 를 lock_guard 한것은 Dead lock 을 피하기 위해 순서를 바꾸다 보니 이처럼 된것, 즉 AccountManager::_mutex 를 t1, t2 스레드에서 먼저 바라보게 처리해야지 Dead lock 을 피할 수 있으니 로직이 이처럼 순서가 바뀐것일 뿐


하지만 이렇게 순서를 바꾸는것은 보통 로직상 이런 경우가 잘 나오진 않는다
            

 

 

방법 2.

Dead lock 이 발생했을 경우 mutex 를 감싸는 warp 클래스를 만들어서 현재 mutex lock 되는 count 가 같은지 다른지를 판별해서 언제 문제가 발생하는지 파악할 수도 있다

 



방법.3

lock 을 거는 것을 각 상태별 graph  state를 만들어 순환 구조가 나오면 Dead lock임으로  이걸 구조화하여 코드로 만들어서 순환이 일어나면 Dead lock 이 일어난다는 것을 디버깅을 통해 알 수 있게 할 수도 있다

반응형
반응형

 

자물쇠로 잠그고 서로 풀어줄 때까지 대기 하고 있는 상태

 

왼쪽 스레드가 자물쇠 1을 점유하고 있고 자물쇠 2를 소유하려고 대기 하고 있는 상태

오른쪽 스레드가 자물쇠 2를 점유하고있고 자물쇠 1을 소유하려고 대기 하고 있는 상태를 말한다

 

이런 원이은 순서가 달라서 인데

 

왼쪽은 1번을 잠그고 2번을 잠그려고 시도 하는것이고

 

오른쪽은 2번을잠그고 1번을 잠그려고 시도 하는것이다

 

이 문제를 해결하려면 순서를 위에 있는 자물쇠 먼저 잠그고 그다음 아래 있는 자물쇠를 잠그도록 규칙을 정해주면 됨

 

 

 

#pragma once
#include <mutex>


class User
{

};

class UserManager
{
public:
	static UserManager* Instance()
	{
		static UserManager instance;
		return &instance;
	}
	User* getUsert(int32 id)
	{
		lock_guard<mutex> guard(_mutex);
		//뭔가 갖고 옴
		return nullptr;
	}

	//
	void processSave();

private:
	mutex _mutex;
};


====================================

#include "pch.h"
#include "UserManager.h"
#include "AccountManager.h"

void UserManager::processSave()
{
	//user lock
	lock_guard<mutex> guard(_mutex);

	//account lock 을 검 여기서
	Account* account = AccountManager::Instance()->getAccount(100);

}

 

#pragma once
#include <mutex>


class Account
{

};

class AccountManager
{
public:
	static AccountManager* Instance()
	{
		static AccountManager instance;
		return &instance;
	}
	Account* getAccount(int32 id)
	{
		lock_guard<mutex> guard(_mutex);
		//뭔가 갖고옴
		return nullptr;
	}

	void processLogin();

private:
	mutex _mutex;


	//map<int32, Account*> _accounts;

};


=========================================
#include "pch.h"
#include "AccountManager.h"
#include "UserManager.h"


void AccountManager::processLogin()
{
	//유저 정보를 갖고와서 로그인 처리를 한다 가정
	// 
	//account lock
	lock_guard<mutex> guard(_mutex);


	//user lock 을 하게 된다 이 함수내부에서 lock 의 lock 이 되는 현상이 발생함
	User* user = UserManager::Instance()->getUsert(100);



}

 

 

 

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>	
#include <atomic>			//멀티 플랫폼에서 작동 가능
#include <vector>
#include <mutex>
#include "AccountManager.h"
#include "UserManager.h"

using namespace std;

void func1()
{
	for (int32 i=0;i<100;++i)
	{
		UserManager::Instance()->processSave();
	}
}


void func2()
{
	for (int32 i = 0; i < 100; ++i)
	{
		AccountManager::Instance()->processLogin();
	}
}

int main()
{

	thread t1(func1);
	thread t2(func2);

	t1.join();
	t2.join();

	cout << "Jobs Done" << endl;


	return 0;
}

 

 

 

 

 

데드락 상황 정리

 

이 문제를 해결하려면 순서를 위에 있는 자물쇠 먼저 잠그고 그다음 아래 있는 자물쇠를 잠그도록 규칙을 정해주면 됨

 

 

반응형
반응형
  • push_back 하면서 capacity가 증가함 하지만 vector 자체는 멀티스레드호환 가능으로 만들어지지 않았음
  • 용량이더 큰 것이 추가 되면서 재할당이 될 수 있는데 다른 두개 중 추가하는 스레드 외에 스레드에서 이미 벡터의 메모리를 제거했을 수 있음 재할당으로 => crash
  • reserve 20000 을 해 줘도 size 변수를 갱신 할때 문제가 발생 할 수 있음 => 제대로 추가가 되지 않음
  • atomic<vector<int32>> 이것도 불가 atomic.load 등의 atomic 자체의 기능을 사용 하는것이기 vector 와 연결(연동)되지 않음
  • 어쨌든 문제가 발생 할 수 있음
    • 그럼 추가 할때 한번에 한 스레드만 허용가능하도록 lock 처리를 해줘야 함
      lock 으로 잠그면 다른 스레드가 접근 불가 unlock 하기 전까지

 

 

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>	
#include <atomic>			//멀티 플랫폼에서 작동 가능
#include <vector>
#include <mutex>

using namespace std;


vector<int32> v;

mutex m;		//화장실의 자물쇠 같은것



void push()
{

	for (int32 i = 0; i < 10000; ++i)
	{
		m.lock();		//자물쇠로 잠그고 하나의 스레드만 진입 가능
		v.push_back(i);
		m.unlock();	//자물쇠 품
	}
}


int main()
{
	thread t1(push);
	thread t2(push);

	t1.join();
	t2.join();

	cout << v.size() << endl;

	return 0;
}

 

하지만 lock 을 쓸때 병합이 너무 심해지면 일반 적인 상황보다는 느려지게 된다

 

재귀적으로 lock 을 걸면?

 

불가능 함, 하지만 recursive mutex 로 가능해짐 

=> 코드가 복잡해지면 이함수 안에서 다른 함수를 호출 할때 재귀적으로 락을 걸고 싶을 때가 있을 수 있음

 

 

문제가 발생 할수 있는 또 다 른 상황

 

for 문에서 

if(i==10)

{

break; 

}

 

로 빠져나올때 unlock() 을 안하면 무한대기 상태가 됨 (Dead lock) 상황이 됨

 

 

수동으로 lock, unlock 하는건 실수가 발생 할수 이씩 때문에 

 

RAII (Resource Acqusition Is Initialization)  

warp 클래스를 만들어 생성자에서 mutex를 받아 잠그고 소멸자에서 풀어주는 방식으로 사용함

 

표준적으로 std::lock_guard<>  가 제공 됨

 

unique_lock 이 존재하는데 lock 하는 시점이 lock_guard 처럼 생성함고 동시에 lock 이 아닌

지연 시켜  lock 을 할 수 있다

 

The class unique_lock is a general-purpose mutex ownership wrapper allowing deferred locking, time-constrained attempts at locking, recursive locking, transfer of lock ownership, and use with condition variables.

The class unique_lock is movable, but not copyable -- it meets the requirements of MoveConstructible and MoveAssignable but not of CopyConstructible or CopyAssignable.

The class unique_lock meets the BasicLockable requirements. If Mutex meets the Lockable requirements, unique_lock also meets the Lockable requirements (ex.: can be used in std::lock); if Mutex meets the TimedLockable requirements, unique_lock also meets the TimedLockable requirements.

 

 

"unique_lock과 lock_guard의 차이점은 lock을 걸 수 있는 시점이다. 둘 다 소멸 시점에 lock이 걸려 있다면 unlock을 수행한다. lock_guarud는 lock과 unlock 사이에서 lock과 unlock을 할 수 없지만 unique_lock은 소멸하기 전에 unlock과 lock을 걸 수 있다.

unique_lock은 lock_guard에 기능이 추가된 버전이라고 생각하면 된다."

 

void push()
{

	for (int32 i = 0; i < 10000; ++i)
	{
		unique_lock<mutex> uniquLock(m, defer_lock);		//이때 잠기지 않고

		uniquLock.lock();		//이때 mutex 를 잠군다

		v.push_back(i);
	}
}

결과는 동일하다 

20000

 

 

 

 

ref : https://en.cppreference.com/w/cpp/thread/unique_lock

ref : https://stormpy.tistory.com/277

 

반응형
반응형

 

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>	
#include <vector>

using namespace std;


int32 sum = 0;

void add()
{
	for (int32 i=0;i<100'0000;++i)
	{
		++sum;
	}
}

void sub()
{
	for (int32 i = 0; i < 100'0000; ++i)
	{
		--sum;
	}
}

int main()
{
	std::cout << sum << endl;

	vector<std::thread> t;
	t.push_back(thread(add));
	t.push_back(thread(sub));

	for (auto& th : t)
	{
		th.join();
	}
	std::cout << " after " << std::endl;
	std::cout << sum << endl;

	

	


	return 0;
}

 

동기화가 제대로 이루어 지지 않는 것을 볼 수 있다

 

 

 

++sum 부분의 어셈 블리를 보면

 

 

 

주석 과 같은 설명으로 처리 되는데

 

증가

00007FF6C65663F9  mov         dword ptr [rbp+4],eax  
00007FF6C65663FC  cmp         dword ptr [rbp+4],0F4240h  
00007FF6C6566403  jge         add+45h (07FF6C6566415h)  
	{
		++sum;
00007FF6C6566405  mov         eax,dword ptr [sum (07FF6C6575450h)]  //sum 변수를 eax 에
00007FF6C656640B  inc         eax     //eax 값 1 증가
00007FF6C656640D  mov         dword ptr [sum (07FF6C6575450h)],eax   //eax 값을  sum 변수에 대입
	}
00007FF6C6566413  jmp         add+24h (07FF6C65663F4h)

 -- 도 비슷하게 처리 된다

 

 

감소

00007FF6C656342C  cmp         dword ptr [rbp+4],0F4240h  
00007FF6C6563433  jge         sub+45h (07FF6C6563445h)  
	{
		--sum;
00007FF6C6563435  mov         eax,dword ptr [sum (07FF6C6575450h)]  
00007FF6C656343B  dec         eax  
00007FF6C656343D  mov         dword ptr [sum (07FF6C6575450h)],eax  
	}
00007FF6C6563443  jmp         sub+24h (07FF6C6563424h)  
}​

 

 

++ 나 -- 는 명령어 한줄이고 아니고 3줄씩 이기 때문에 타이밍상 ++ 실행 하던 도중에 sum 이 1기 되기 전에

sum 가 중간에 먼저 실행되어 sum 은 -1 이 되고 이때는 -1 이지만 다시 나머지 ++ 함수로 되돌아가 이전 레지스터에 있떤 eax 값 1 값이 sum 에 덮어 써지게 됨으로 sum 은 1이 된다,  sum 과 add 를 동시에 실행했음에도 불구하고 

 

즉 동기화의 문제가 발생하게 된다

그리고 이런 상태가 계속 누적 되면 결과는 이처럼 완전 잘못된 상태가 된다 

 

 

그래서 이걸 atomic 하게 만들어 문제를 해결 할수 있다 (더이상 쪼개 질 수 없는 단위)

Atomic : all or nothing 즉 다 실행되거나 모두 실행되지 않는 연산을 말한다

연산이 그렇게 빠르진 않음 

 

 

sum 을 atomic 으로 처리하면 동기화 문제를 해결 할수 있음

 

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>	
#include <atomic>			//멀티 플랫폼에서 작동 가능
#include <vector>

using namespace std;


//이렇게 사용하면 atomic 사용 끝
atomic<int32> sum = 0;

void add()
{
	for (int32 i=0;i<100'0000;++i)
	{
		++sum;
	}
}

void sub()
{
	for (int32 i = 0; i < 100'0000; ++i)
	{
		--sum;
	}
}

int main()
{
	std::cout << sum << endl;

	vector<std::thread> t;
	t.push_back(thread(add));
	t.push_back(thread(sub));

	for (auto& th : t)
	{
		th.join();
	}
	std::cout << " after " << std::endl;
	std::cout << sum << endl;

	

	


	return 0;
}

 

 

 

 

atomic.fetch_add 를 사용해도 결과는 같다

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>	
#include <atomic>			//멀티 플랫폼에서 작동 가능
#include <vector>

using namespace std;


//이렇게 사용하면 atomic 사용 끝
atomic<int32> sum = 0;

void add()
{
	for (int32 i=0;i<100'0000;++i)
	{
		//++sum;
		sum.fetch_add(1);
	}
}

void sub()
{
	for (int32 i = 0; i < 100'0000; ++i)
	{
		//--sum;
		sum.fetch_add(-1);
	}
}

int main()
{
	std::cout << sum << endl;

	vector<std::thread> t;
	t.push_back(thread(add));
	t.push_back(thread(sub));

	for (auto& th : t)
	{
		th.join();
	}
	std::cout << " after " << std::endl;
	std::cout << sum << endl;

	

	


	return 0;
}

 

 

어셈블리 내용을 보면 명령어가 한출로 처리 되는 것을 알수 있다 (내부적으로 다른 함수를 실행시켜서 완료 되면 빠져나옴)


00007FF6E24A29A3  jge         add+4Eh (07FF6E24A29BEh)  
	{
		//++sum;
		sum.fetch_add(1);
00007FF6E24A29A5  mov         r8d,5  
00007FF6E24A29AB  mov         edx,1  
00007FF6E24A29B0  lea         rcx,[sum (07FF6E24B5450h)]  

//한줄로 처리 됨
00007FF6E24A29B7  call        std::vector<std::thread,std::allocator<std::thread> >::capacity (07FF6E24A1857h)  


	}

 

반응형
반응형

 

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread>	//11부터 thread 가 다른 플랫폼에서도 구현 가능되도롯 멀티플랫폼으로 지원 됨 => linux 에서도 실행 가능
#include <vector>

using namespace std;

void HelloThread(int a)
{
	std::cout << "Hello Thread " << a << std::endl;
}


int main()
{
	vector<std::thread> t;
	t.resize(1);

	auto idtest = t[0].get_id();			//관리하고 있는 스레드가 없다면 id 는 0

	t[0] = std::thread(HelloThread, 10);

	if (t[0].joinable())
	{
		t[0].join();
	}

	std::cout <<" main " << std::endl;

	


	return 0;
}

 

 

반응형
반응형

#include "pch.h"
#include <iostream>
#include "CorePch.h"
#include <thread> //11부터 thread 가 다른 플랫폼에서도 구현 가능되도롯 멀티플랫폼으로 지원 됨 => linux 에서도 실행 가능

void HelloThread()
{
std::cout << "Hello Thread" << std::endl;
}


int main()
{
//cout 은 os 커널에 요청하여 요청된 처리가 다시 콘솔화면으로 오는 느린 실행이다
//HelloWorld();

//std::thread t(HelloThread);
std::thread t;

auto idtest = t.get_id(); //관리하고 있는 스레드가 없다면 id 는 0

t = std::thread(HelloThread);


int32 count = t.hardware_concurrency(); //CPU 코어 개수는 몇개인지 힌트를 줌 => 논리적으로 실행할 수 있는 프로세스 개수 ,  100% 확실한 동작이 되진 않을 수있어서 경우에따라 0 을리턴하면 제대로 조사가 안된 것  

auto id = t.get_id(); // 각 쓰레드마다 부여되는 id , 쓰ㅜ레드 사이에서는 id 가 겹치지 않는다
t.detach(); //join 반대로 std::thread 객체에서 실제 스레드를 분리 => 스레드 객체 t 와 연결을 끊어줌 => 만들어진 스레듸이 정보를 더이상 사용 할수 없음

//쓰레드를 떼어내면, 쓰레드가 독립적으로 실행됩니다. 프로그램이 종료되면 떼어진 쓰레드는 동작이 멈추게 됩니다. 


bool states = t.joinable(); //detach 되거나 연동된 슬레드가 없는 상태를 판별하기 위한 함수

//t.join(); //스레드가 끝날때까지 대기

if (t.joinable())
{
t.join();
}


std::cout << "Hello Main" << std::endl;

return 0;
}

 

 

위 코드에선 detach 하여 Join 을 하지 못하기 때문에 Hello Main 이 높은 확률로 나오는 것을 알 수 있다

반응형
반응형

RxJava - 물리적인 쓰레드와 논리적인 쓰레드의 이해

 

물리적인 쓰레드와 논리적인 쓰레드의 이해

  • 물리적인 쓰레드는 하드웨어와 관련이 있고, 논리적인 쓰레드는 소프트웨어와 관련이 있다.
  • 물리적인 쓰레드를 이해하기 위해서는 CPU의 코어를 먼저 알아야 한다.
  • 그럼 코어란?
    • CPU의 명령어를 처리하는 반도체 유닛
    • 코어의 갯수가 많으면 명령어를 병렬로(parallel) 더 많이 더 빠르게 처리할 수 있다.
  • 물리적인 쓰레드는 물리적인 코어를 논리적으로 쪼갠 논리적 코어이다.

논리적인 쓰레드란?

  • 자바 프로그래밍에서 사용하는 그 쓰레드가 논리적인 쓰레드이다.
  • 논리적인 쓰레드는 프로세스 내에서 실행되는 세부 작업의 단위이다.
  • 프로세스는 컴퓨터에서 실행할 수 있는 실행 파일(프로그램)을 실행하면 생기는 인스턴스이다.
  • 논리적인 쓰레드의 생성 개수는 이론적으로는 제한이 없지만 실제로는 물리적인 쓰레드의 가용 범위내에서 생성할 수 있다.

물리적인 쓰레드와 논리적인 쓰레드의 이해

  • 병렬성 : 실제로 작업들이 병렬로 작업되는 성질 (동일한 시간대에 여러개의 쓰레드들의 동시에 실행이된다.)
  • 동시성 : 실제로 병렬적으로 실행이 되는 것처럼 보이지만 여러 개의 작업이 짧은 시간에 번갈아 가면서 병렬로 처리되는 것처럼 시행되는 성질을 동시성이라고 한다.

 

ref : https://yunzai.dev/posts/RxJava_%EB%AC%BC%EB%A6%AC%EC%A0%81%EC%9D%B8_%EC%93%B0%EB%A0%88%EB%93%9C%EC%99%80_%EB%85%BC%EB%A6%AC%EC%A0%81%EC%9D%B8_%EC%93%B0%EB%A0%88%EB%93%9C%EC%9D%98_%EC%9D%B4%ED%95%B4/

반응형
반응형


Thread란

하나의 프로그램내에서 여러 개의 실행 흐름을 두기 위한 모델

하나의 프로세서(실행 중인 프로그램)에서 각 독립적인 일의 단위인 스레드(Thread)로 여러 작업을 처리할 수 있다. 즉 하나의 프로세서에서 병렬적으로 여러 개 작업을 처리하기 위해서는 각 작업을 스레드화하여 멀티스레딩이 가능하게 해야 한다.

Process란

간단하게 말하면 실행중인 프로그램.

프로세스는 사용 중인 파일, 데이터, 프로세서의 상태, 메모리 영역 주소 공간, 스레드 정보, 전역 데이터가 저장된 메모리 부분 등 수 많은 자원을 포함하는 개념. 종종 스케쥴링의 대상이 되는 작업이라고 불리기도 함

쓰레드와 프로세스의 차이점

프로세스는 완벽히 독립적이기 때문에 메모리 영역(Code, Data, Heap, Stack)을 다른 프로세스와 공유를 하지 않지만, 쓰레드는 해당 쓰레드를 위한 스택을 생성할 뿐 그 이외의 Code, Data, Heap영역을 공유한다.

텍스트 : PC(피시 카운터 - 다음번에 실행 될 명령어의 주소를 가지고 있는 레지스터), 프로그램 코드 저장

데이터: 글로벌 변수, 스태틱 변수 저장

 :메모리관리, 동적 메모리 할당(시스템 콜로 관리)

스택: 임시 데이터 저장- 로컬 변수, 리턴 어드레스

스택을 독립적으로 할당하는 이유

  • 스택은 함수 호출 시 전달되는 인자, 되돌아갈 주소값 및 함수 내에서 선언하는 변수 등을 저장하기 위해 사용되는 메모리 공간이다. 따라서 스택 메모리 공간이 독립적이라는 것은 독립적인 함수 호출이 가능하다는 것이고, 이는 독립적인 실행 흐름이 추가되는 것이다. 결과적으로 실행 흐름의 추가를 위한 최소 조건이 독립된 스택을 제공하는 것이다. 

코드 영역을 공유

  • 프로세스는 독립적인 구조이기 때문에 다른 프로세스의 Code영역에 있는 함수를 호출할 수 없다.
  • 쓰레드는 Code영역을 공유하기 때문에 두 개 이상의 쓰레드가 자신이 포함된 프로세스의 Code영역에 있는 함수를 호출할 수 있다.

데이터 영역과 힙 영역을 공유

  • 전역 변수와 동적 할당된 메모리 공간을 공유할 수 있고, 이를 통해 쓰레드 간 통신을 할 수 있지만  동시에 메모리에 접근하기 때문에 주의해야 한다.

멀티 프로세스의 문제점

  • 두 개의 프로세스는 완전히 독립된 두 개의 프로그램 실행을 위해서 사용되기 때문에 컨텍스트 스위칭(프로세스의 상태 정보를 저장하고 복원하는 일련의 과정)으로 인한 성능 저하가 발생
  • 쓰레드는 하나의 프로그램 내에서 여러 개의 실행 흐름을 두기 위한 모델이다.
  • 쓰레드는 프로세스처럼 완벽히 독립적인 구조가 아니다. 쓰레드들 사이에는 공유하는 요소가 있다.
  • 쓰레드는 이 공유하는 요소로 인해 컨텍스트 스위칭에 걸리는 시간이 프로세스보다 짧다.

쓰레드가 프로세스보다 컨텍스트 스위칭이 빠른 이유

 쓰레드가 프로세스보다 컨텍스트 스위칭이 빠른 이유는 메모리 영역을 공유하기 때문이다. 실제로 공유되는 데이터가 있고 아닌 데이터가 있다.

sp(Stack Pointer), fp(Frame Pointer), pc(Program Counter) 레지스터

  • pc는 실행해야 할 명령어의 위치를 가리키는 레지스터이다.
  • 쓰레드별로 main함수를 독립적으로 가지고 있고, 함수 호출도 독립적으로 진행되기 때문에 쓰레드별로 현재 pc값은 달라야 한다. 따라서 pc는 공유되지 않는다.
  • 쓰레드는 독립적인 스택을 가지기 때문에 스택의 정보를 담고 있는 sp와 fp는 공유되지 않는다.

범용적으로 사용가능한 레지스터

  • 시스템을 어떻게 디자인 하느냐에 따라 달라지는 것이기 때문에 일반적으로 공유된다고 할 수 없다.

캐시메모리

  • 캐시 메모리는 CPU에서 한번 이상 읽어 들인 메인 메모리의 데이터를 저장하고 있다가 CPU가 다시 그 메모리에 저장된 데이터를 요구할 때 메인 메모리를 통하지 않고 바로 값을 전달하는 용도로 사용된다.
  • 프로세스 사이에서 공유하는 메모리가 하나도 없기 때문에 컨텍스트 스위칭이 발생하면 캐쉬에 있는 모든 데이터를 모두 리셋하고 다시 캐쉬 정보를 불러와야 한다.
  • 쓰레드는 캐쉬 정보를 비울 필요가 없기 때문에 프로세스와 쓰레드의 컨텍스트 스위칭 속도의 차이는 이때 발생한다.

Windows에서의 프로세스와 쓰레드

  • Windows 입장에서 프로세스는 쓰레드를 담는 상자에 지나지 않는다.
  • 또한 Windows에서 프로세스는 상태(Running, Ready, Blocked)을 지니지 않는다. 상태를 지니는 것은 스레드이다.
  • 스케줄러가 실행의 단위로 선택하는 것도 프로세스가 아닌 스레드이다.
  • 프로세스 컨텍스트 스위칭 역시 실제로 오버헤드가 발생하는 부분은 서로 다른 프로세스에 포함된 스레드 간의 컨텍스트 스위칭시 발생한다.







[OS]메모리 관점에서 본 쓰레드(thread)



메모리 관점에서 본 process의 특징

 각각의 프로세스는 메모리 공간에서 독립적으로 존재한다.



<출처:https://elgaabeb.wordpress.com/>


 전에 본 적이 있는 그림이다. 이 그림은 프로세스를 구성하는 메모리 공간의 모습이다. 각각의 프로세스는 자신만의 이런 메모리 구조를 가진다. 프로세스A, B, C가 존재한다면 각각 프로세스는 모두 위와 같은 구조의 메모리 공간을 가진다.

 독립적인만큼 다른 프로세스의 메모리 공간에 접근할 수도 없다. A가 B의 메모리 공간에 접근하면 재앙이 발생할 수도 있다. 만약 A가 뜬끔없이 Chrome의 메모리 공간에 접근한다고 생각해보라. Chrome의 안정성이 보장될 수 있겠는가? 더 뜬금없이 A가 windows의 메모리 공간에 접근한다고 생각해보라. 운영체제의 메모리 공간에 접근하여 뭔가를 변경한다면 심각한 문제가 발생할 수 있다.(물론 운영체제의 메모리 공간에 접근하는 것은 원천적으로 불가능하다) 그러므로 프로세스의 안정성을 보장하기 위해서는 프로세스는 각각 독립된 메모리 공간을 가져야 한다.

 그러면 혹시 A에서 연산한 결과를 B에서 받아서 사용하고 싶다면 어떻게 해야할까?


IPC(inter process communication)




<출처: http://madhusudhanrc.blogspot.kr/2012/08/inter-process-communicationipc.html>


 A의 메모리 공간에 B가 직접 접근하지 못하기 때문에 프로세스간의 통신을 하는 특별한 방법들이 존재한다. 메일슬롯(mailslot), 파이프(pipe) 등이 바로 프로세스 간의 통신 즉, IPC의 예들이다.

 IPC에 대한 자세한 설명은 생략~

 중요한 점은 프로세스는 독립적인 메모리 공간을 지니기 때문에 IPC를 통하지 않고는 통신할 수 없다는 사실이다. 그리고 프로세스가 여럿이 병렬적으로 실행되기 위해서는 필연적으로 컨텍스트 스위칭이 발생할 수 밖에 없다.


프로세스가 지니는 제한

 독립적인 메모리 공간으로 컨텍스트 스위칭이 발생한다.
 통신하기 위해서는 IPC가 필요하다.

 이 두 가지 문제점을 한 번에 해결할 수 있는 녀석이 쓰레드이다.


쓰레드

  쓰레드(thread)는 하나의 프로그램 내에 존재하는 여러 개의 실행 흐름을 위한 모델이다. 우리가 생각하는 프로그램이 실행되기 위해서 하나의 실행흐름으로 처리할 수도 있지만 다수의 실행흐롬으로 처리할 수도 있다.(multi-thread)

 다시 말해서 프로세스에 존재하는, 프로세스가 실행되는 흐름이다.






<출처: http://en.wikipedia.org/wiki/Thread_(computing)>


 wikipedia를 보면 쓰레드를 설명하기 위해 위와 같은 그림들을 보여준다. 아래 그림을 보면 프로세스 내부에 2개의 쓰레드가 존재한다. 그리고 시간의 방향을 따라 쓰레드가 실행되고 있다. 그림에서 볼 수 있듯이 쓰레드는 프로세스와 별개가 아닌 프로세스를 구성하고 실행하는 흐름이다.

 프로세스에서도 그러했듯이 이번에도 메모리 관점에서 쓰레드를 보자. 프로세스와 어떤 차이가 있는지 뚜렷하게 알 수 있을 것이다.


메모리 공간에서의 쓰레드

<출처: http://www-01.ibm.com/support/knowledgecenter/SSLTBW_1.12.0/com.ibm.zos.r12.euvmo00/euva3a00451.htm>

 위 그림은 프로세스와 쓰레드의 메모리 구조의 차이점을 보여준다. 왼쪽의 프로세스는 이미 봤기 때문에 설명하지 않고, 오른쪽의 thread에 주목하자. 앞서 말한 것처럼 쓰레드는 프로세스 안에 존재하는 실행흐름이다. 메모리 구조 역시 그러하다. 하지만 특이한 점은 쓰레드는 프로세스의 heap, static, code 영역 등을 공유한다는 사실이다. 각각의 프로세스가 독립적인 stack, heap, code, data 영역을 가진 반면에, 한 프로세스에 속한 쓰레드는 stack 영역을 제외한 메모리 영역은 공유한다. 

//첨언 : 쓰레드는 stack 뿐만 아니라 레지스터또한 독립적인 공간이다
//굳이 중요도를 따지자면 레지스터의 독립적인 공간에 좀 더 관심을 둘 필요가 있다

 쓰레드가 code 영역을 하기 때문에 한 프로세스 내부의 쓰레드들은 프로세스 가 가지고 있는 함수를 자연스럽게 모두 호출할 수 있다.

 뿐만 아니라 쓰레드는 data, heap 영역을 공유하기 때문에 IPC 없이도 쓰레드 간의 통신이 가능하다. 동일한 프로세스 내부에 존재하는 쓰레드 A, B가 통신하기 위해 heap 영역에 메모리 공간을 할당하고, 두 쓰레드가 자유롭게 접근한다고 생각하면 된다.

 쓰레드는 프로세스처럼 스케줄링의 대상이다. 이 과정에서 컨텍스트 스위칭이 발생한다. 하지만 쓰레드는 공유하고 있는 메모리 영역 덕분에 컨텍스트 스위칭 때문에 발생하는 오버헤드(overhead)가 프로세스에 비해 작다. 



ref : http://brownbears.tistory.com/39

ref : https://mooneegee.blogspot.com/2015/01/os-thread.html


반응형
반응형

http://yesarang.tistory.com/214


오랜만에 S/W개발 관련된 글을 올립니다. 요즘 블로그에 제대로 글을 쓰지도 못하는데 계속해서 RSS 구독자 카운트는 늘어만가니 어찌된 조화인지 모르겠습니다. 늘어나는 구독자수의 압박을 견디다 못해 오래전에 약속드렸던 reentrant와 thread-safe의 차이점에 대해 말씀드릴까 합니다.

우선 reentrance 를 우리말로 옮기자면 재진입성쯤 되고, thread-safety는 multithread-safety라고도 하니 다중쓰레드안전성쯤 되겠습니다. 요즘 워낙 multi-threaded programming은 일반화된 것이라 thread-safety에 대해서는 어느 정도 개념들을 챙기고 계시리라 생각하고, reentrance가 thread-safety와 어떻게 다른가를 설명하면서 그 둘간의 차이점을 설명해 보도록 하겠습니다.

어떤 루틴 또는 프로그램이 Thread-safe하다라고 하면 여러 쓰레드에 의해 코드가 실행되더라도 실행 결과의 correctness가 보장되는 것을 뜻합니다. 이 정의에서 중요한 점은 실행 결과의 correctness가 보장되어야 한다는 것입니다. 즉, 여러 쓰레드에 의해 코드가 진짜로 동시에 수행되던 보이기에 동시에 수행되던 간에 실행 결과의 correctness가 보장되면 Thread-safe하다라고 말하는 거죠.

이에 비해 어떤 루틴 또는 프로그램이 Reentrant하다라고 하면 여러 쓰레드에 의해 코드가 동시에 수행될 수 있고, 그런 경우에도 실행 결과의 correctness가 보장되는 것을 뜻합니다.

이해가 되시나요 ? 차이점이 안 보이신다구요 ? 그럼 다시 한 번 말씀드리죠.

어떤 루틴 또는 프로그램이 Thread-safe하다라고 하면 여러 쓰레드에 의해 코드가 실행되더라도 실행 결과의 correctness가 보장되는 것을 뜻합니다.

이에 비해 어떤 루틴 또는 프로그램이 Reentrant하다라고 하면 여러 쓰레드가 코드를 동시에 수행할 수 있고, 그런 경우에도 실행 결과의 correctness가 보장되는 것을 뜻합니다.

이제 보이시죠 ? Reentrant 특성이 훨씬 강력한 제약조건이라는 것이 느껴지시나요 ? 구체적으로 코드에서는 어떻게 달라지나 예제를 통해서 알아보겠습니다.

C 언어 표준라이브러리에 strtok()이라는 문자열 함수가 있는 건 다 알고 계시겠죠(모르시는 분은 가서 엄마 젖 좀 더 먹고 오세요~ ㅋㅋㅋ 농담입니다). strtok() 의 매뉴얼에 다음과 같이 설명되어 있습니다.

     The strtok() function can be used to break the string
     pointed to by s1 into a sequence of tokens, each of which is
     delimited by one or more characters from the string pointed
     to by s2. The strtok() function considers the string s1 to
     consist of a sequence of zero or more text tokens separated
     by spans of one or more characters from the separator string
     s2. The first call (with pointer s1 specified) returns a
     pointer to the first character of the first token, and will
     have written a null character into s1 immediately following
     the returned token. 
The function keeps track of its position
     in the string between separate calls, so that subsequent
     calls (which must be made with the first argument being a
     null pointer) will work through the string s1 immediately
     following that token.
 In this way subsequent calls will work
     through the string s1 until no tokens remain. The separator
     string s2 may be different from call to call. When no token
     remains in s1, a null pointer is returned.


strtok()을 사용하는 예제를 보면 그 의미가 더 명확해집니다.

#include <cstring>

using namespace std;

int
main()
{
  // strtok() 의 첫번째 argument 는 const char * 가 아닌 char * 이므로
  // 새로 할당된 메모리로 넣음
  char* str = (char*)malloc(strlen("A;B;C;D;E;F;G")+1);
  strcpy(str, "A;B;C;D;E;F;G");
  cout << str << " ==> ";

  char* tok = strtok(str, ";");
  while (tok != 0)
  {
    cout << tok << " ";
    tok = strtok(0, ";");
  }
  cout << endl;

  return 0;
}


위와 같이 작동한다는 것은 strtok() 내부적으로 다음과 같이 다음번 호출 때 문자열 분석을 시작할 위치를 기억하고 있다는 걸 뜻합니다. 그리고 여러번의 호출에 걸쳐 이 함수가 제대로 작동하기 위해서는 그 위치는 당연히 static 으로 선언되어 있겠지요.

char* strtok(char* src, const char* delim)
{
  // src, delim 이 NULL 인지, delim 이 "" 인지 체크하는 코드는 생략
  char* tok;
  static char* next;     // 분석을 시작할 위치
  if (src != NULL)
    next = src;
  tok = next;

  // boundary condition check
  if (*next == '\0')
    return NULL;

  // 분석 시작
  for (; *next != '\0'; ++next)
  {
    if (*next in delim) // pseudo code
    {
      *next = '\0';
      ++next;
      break;
    }
  }

  return tok;
}

위와 같은 코드는 Reentrant 하지도 않고 Thread-Safe 하지도 않을 것입니다. 위 코드가 여러 쓰레드에 의해 수행될 경우, next 라는 변수가 서로 다른 쓰레드에 위해 공유가 되므로(함수 내에서 static으로 선언된 변수는 stack에 할당되는 것이 아니라 전역 메모리 영역에 할당됩니다) next 변수가 깨질 가능성이 생기기 때문입니다. 이런 상황에 대한 구체적인 예를 다음과 같이 들 수 있을 것 같습니다.



Multi-thread 상의 strtok() 호출

위 그림에서 Call strtok() 앞의 숫자는 호출 순서를 뜻하는 것이고 next가 가리키는 화살표의 선은 Call strtok()을 호출한 후 next 변수의 상태값이라고 생각하시면 됩니다. 그림의 예에서는 3, 4 번까지는 원래 예상하는 결과 대로 리턴이 되겠지만 5번 호출에서는 "F"가 리턴될 것이고, 6번 호출에서는 NULL이 리턴될 것입니다(왜 그런지는 제가 작성한 strtok()을 따라가면서 알아보세요. ^^). 이런 결과가 나오는 것은 모두 next 라는 변수가 양쪽 Thread에 의해서 값이 갱신되기 때문입니다.

그렇다면 strtok()을 thread-safe 하게 만들기 위해서는 어떻게 해야할까요 ? 다음 처럼 strtok() 내부 구현을 바꿔서 내부적으로 next 변수에 대해 lock을 걸도록 만들면 될까요 ?

os_specific_lock lck;

char* strtok(char* src, const char* delim)
{
  // src, delim 이 NULL 인지, delim 이 "" 인지 체크하는 코드는 생략
  char* tok;
  lock(&lck);
  static char* next;     // 분석을 시작할 위치
  if (src != NULL)
    next = src;
  tok = next;

  // boundary condition check
  if (*next == '\0')
  {
    unlock(&lck);
    return NULL;
  }

  // 분석 시작
  ......

  unlock(&lck);
  return tok;
}


언뜻 생각하면 위와 같이 구현된 strtok()이 thread-safe하다고 생각할 수도 있겠지만 실상은 전혀 그렇지 않습니다. 위와 같이 구현된 strtok()은 thread 가 동시에 next 변수를 수정하는 것을 막아주기 하지만 그림에서 나타낸 예를 제대로 처리해주지 못합니다. strtok()이 thread-safe 하기 위해서는 strtok() 호출 한번에 대해 thread-safe하면 되는 것이 아니라 전체 tokenizing 과정이 모두 thread-safe 해야 하기 때문입니다. 아~ 그렇군요. 그렇다면 tokenizing 과정 전체에 대해 lock을 걸면 되겠네요.

#include <cstring>

using namespace std;

os_specific_lock lck;

void* tokenizer(void* s)
{
  char* str = (char*)s;
  cout << str << " ==> ";

  lock(&lck);
  char* tok = strtok(str, ";");
  while (tok != 0)
  {
    cout << tok << " ";
    tok = strtok(0, ";");
  }
  unlock(&lck);
  cout << endl;

  return 0;
}

int
main()
{
  pthread_t thr1, thr2;
  char* str1, str2;      // 어찌어찌해서 초기화됐다고 가정
  pthread_create(&thr1, NULL, tokenizer, str1);
  pthread_create(&thr2, NULL, tokenizer, str2);

  return 0;
}

어때요? 맘에 드시나요 ? tokenizing 과정 전체에 대해 lock을 걸어 버렸으니 제가 그림에서 제시한 상황이 발생하지 않겠네요. 그래도 어쩐지 꺼림직하지 않으세요 ? 마치 구더기 한 마리 잡으려고 불도저 쓰는 격이라고나 할까요. 위에 있는 tokenizer는 아주 간단해서 그렇지 만약에 token 하나 하나에 대해서 복잡한 처리를 한다면 어떻게 될까요 ? 그리고 복잡한 처리 과정 중에 그 쓰레드가 I/O 이벤트를 기다린다면 어떻게 될까요 ? 갈수록 태산이네요. 그죠 ? CPU가 아무리 빨라도 아무리 많은 Multi Core 들을 가지고 있어도 전혀 그런 성능을 활용하지 못하는 코드가 되어 버립니다. 이 사태를 어떻게 해결해야 하나요. 여러분이 진정한 엔지니어라면 여기서 멈춰서는 안돼죠. 해결책을 생각할 시간을 드리겠습니다.
1초.
2초.
3초.
4초.
5초.
6초.
7초.
8초.
9초.
10초 삐~~~~~!!!.

생각나셨나요 ? 멋진 해결책을 가지고 계신 분들이 있으리라 생각합니다. 자~ 그럼 다음과 같이 strtok() 인터페이스 및 구현을 바꾸면 어떨까요 ?

char* strtok(char* src, const char* delim, char** start)
{
  // src, delim 이 NULL 인지, delim 이 "" 인지 체크하는 코드는 생략
  char* tok;
  char* next;     // 분석을 시작할 위치. static 을 없애고 start 라는 입력
                  // 으로 초기화함
  if (src != NULL)
    next = src;
  else
    next = *start;


  // boundary condition check
  if (*next == '\0')
    return NULL;

  // 분석 시작
  tok = next;
  for (; *next != '\0'; ++next)
  {
    if (*next in delim) // pseudo code
    {
      *next = '\0';
      ++next;
      break;
    }
  }

  *start = next;
  return tok;
}


이렇게 구현할 경우 thread-safe 하기도 하지만 reentrant하기도 합니다. 위와 같은 strtok() 내에서 사용되는 모든 변수는 stack에 할당되는 자동변수이므로 각각 독립적인 stack을 갖는 thread가 동시에 위와 같은 strtok()을 수행한다해도 전혀 문제가 없습니다. 실상 위와 같이 구현한 strtok()은 POSIX에 의해 표준화되어 있는 strtok_r()이나 MS Visual C++ 에서 제안한 safe string library에 표함되어 있는strtok_s()와 동일합니다.

reentrant 한 코드는 thread 간에 공유하는 자원 자체가 없어야만 하는 코드입니다. 쓰레드간에 동기화 메커니즘 자체가 필요 없게 만드는 코드이고, 따라서 multi threading 환경에서 여러 쓰레드가 해당 코드를 진짜로 동시에 실행하더라도-동시에 실행되는 것처럼 보이기만 하는 것이 아니라-아무런 문제가 없습니다. 그렇지만 thread-safe 하다는 것은 단지 여러 쓰레드에 의해 실행되더라도 문제만 없으면 된다는 완화된 조건이므로 공유하는 자원이 있더라도 이것을 여러 쓰레드가 동시에 접근하지 못하도록 locking mechanism 같은 것으로 막아주기만 하면 됩니다. 결국 thread-safe한 코드는 multi threading 환경에서 reentrant 코드보다는 효율성이 떨어질 가능성이 높습니다. 해당 코드를 수행하고 있는 thread 가 공유 자원에 대한 lock 이 풀리기를 기다리는 동안은 다른 thread 의 수행을 막아버리기 때문입니다.

또 다른 간단한 예를 통해 thread-safe와 reentrant 의 차이점을 살펴 보겠습니다.

// 출처: Wikipedia
int g_var = 1;

int f()
{
  g_var = g_var + 2;
  return g_var;
}

int g()
{
  return f() + 2;
}

위와 같은 코드에서 g() 또는 f()를 호출하는 코드는 모두 thread-safe 하지 않습니다. thread-safe 하지 않으면 reentrant 하지도 않습니다. 위 코드를 thread-safe 하게 하려면 어떻게 해야할까요 ? 다음과 같이 해야겠죠.

int g_var = 1;
os_specific_lock lck;

int f()
{
  lock(&lck);
  g_var = g_var + 2;
  unlock(&lck);
  return g_var;
}

int g()
{
  return f() + 2;
}

이제 thread-safe하게 됐습니다. reentrant 할까요 ? 아니올시다입니다. 여러 쓰레드가 f() 함수를 동시에 수행할 수 없기 때문입니다. 다시 말하면 한 쓰레드가 lock 을 걸고 있다면 다른 쓰레드는 lock 이 풀릴 때까지 기다려야 하므로 reentrant 하지 않은 것입니다. 위 코드를 reentrant하게 고치려면 어떻게 하면 될까요 ?

// 출처: Wikipedia
int f(int i) 

  int priv = i;
  priv = priv + 2;
  return priv;
}

int g(int i)
{
  int priv = i;
  return f(priv) + 2;
}

아예 전역 변수를 없애 버려서 쓰레드간 동기화가 필요 없게 만들어 버렸습니다. 위 코드에서는 워낙 lock이 걸리는 시간이 적을 것이므로 성능에 거의 영향을 미치지 않겠지만, lock이 걸리는 기간이 길어진다면 성능에 상당한 영향을 미치겠지요. 특히 요즘 유행하는 Multi-Core CPU에서는 그냥 thread-safe 코드와 reentrant 코드간의 성능 차이가 더 많이 발생할 것입니다.

보통은 thread-safe 코드를 만드는 것보다는 reentrant한 코드를 만드는 게 더 어렵습니다. 그리고, thread-safe 한 코드는 내부 구현만 바꾸면 되는 경우가 많지만 reentrant한 코드를 만드는 것은 위에서 제가 제시한 두 가지 예(strtok(), f())처럼 아예 인터페이스 자체를 재설계해야 하는 경우가 많습니다.

가능하다면 그냥 thread-safe한 코드를 만드는 것보다는 reentrant한 코드를 만드는 것이 성능상 훨씬 좋은 선택입니다. 물론 reentrant 한 코드를 만드는 것이 불가능한 경우도 있겠지만, 고민해보면 reentrant한 코드를 만들 수 있는 경우가 상당히 있습니다. 요즘처럼 Multi-Core CPU가 갈수록 일반화되고 있는 상황에서 Reentrance는 다시 한 번 주목을 받아야 할 것입니다.

그럼 Coding Guideline스러운 멘트로 이번 글을 마무리 하도록 하겠습니다. ^^

"Thread-safe한 코드보다는 Reentrant한 코드로 작성하라"

반응형
반응형

 __declspec(align('16'))를 사용하는 정식 매개 변수는 정렬되지 않습니다.

  

2012/02/27 15:22

복사http://blog.naver.com/downkhg/90137358810


이 에러는 주로 라이브러리에서 정의되어있는 구조체나 클래스가 매개변수로 선언되었을때,

나타난다.

해결방법은 파라메터에 &를 붙여주면 해결된다.


참조: http://2-up.tistory.com/481

반응형
반응형

timeBeginPeriod(1)일단 시스템의 타이밍 해상도를 1ms로 바꿈=>시간 함수의 정밀도를 높이는 효과

를 해 주시면 Sleep()의 해상도를 높일 수 있습니다. 
테스트 해보니 평균 15ms 걸리던 것이 해상도 높인 후에는 약 2ms 걸리네요.        http://blog.naver.com/small3014?Redirect=Log&logNo=80175959872

[출처] Thread & Sleep|작성자 작은아이




프로세스들 중에서의 우선순위 설정


SetPriorityClass(GetCurrentProcess(),REALTIME_PRIORITY_CLASS);



프로내의 스레드 우선순위 설정

SetThreadPriority(hThread[0], THREAD_PRIORITY_ABOVE_NORMAL );

SetThreadPriority(hThread[1], THREAD_PRIORITY_HIGHEST);

SetThreadPriority(hThread[2], THREAD_PRIORITY_NORMAL);

SetThreadPriority(hThread[3], THREAD_PRIORITY_LOWEST);




http://blog.naver.com/ascbbs?Redirect=Log&logNo=40001109764



Windows는 실시간 OS가 아니기 때문에 근본적으로 실시간은 지켜질수 없다.

그러나 여러 방법을 사용하면 가능할 수도 있다.

방법으론 다음과 같이 크게 3가지가 있을수 있다.

 

1. RTC(RealTime Clock) 인터럽트를 이용하는 방법

2. 실시간을 위한 윈도우 써드파트를 이용하는 방법

3. 윈도우의 scheduling과 Process Class./Thread Priority를 이용하는 방법

 

1번은 DDK로 IRQ8번을 후킹하여 RTC가 갖고있는  Period Interrupt기능을 이용할 수 있다.

이를 위해서는 디바이스 드라이버 프로그래밍을 할 줄 알아야 하는 번거로움이 있고

IDT(INterrupt Descriptor Table)을 직접 조작할수 있는 기술이나 다른 사람이 짠 프로그램을

빼끼는 능력이 있어야 하지만 내가 해본바로는 하드웨어 인터럽트기 때문에 hard Realtime을

구현할 수 있다. 이에 대한 내용은 이미 내 블로그에 찾아보면 있을 것이다.

대신에 RTOS처럼 여러 여러 쓰레드를 사용할수 없고 2의 배수로 나눈 시간으로 밖에

주기를 설정할 수 없는 단점이 있다.  나는 512Hz로 현재 모션 컨트롤러를 제어 하고 있다.

 

둘째로 돈이 가장 많이 드는 방법으로 Third Part를 사서 사용하는 방법이다.

이것들은 내가 본것만도 몇가지 되지만 그중 가장 괜찮다고 하는게 INTime이라는 것이다.

이것이 설치되면 Window커널위에 INTime RT 커널이 위치해서 윈도를 완전히 장악 하고

있는것 같다.  Realtime의 구현은 일반 Application에서 되는것이 아니고 따로 RT module을

작성해야하며 Application과 다양한 방법의 통신으로 운영되는 것 같다.

 

셋째로 윈도우 자체 기능을 최대한 활용하는것으로 Hard RealTime은 될수 없지만

어느정도는 잘되는듯 하다.

일단 시스템의 타이밍 해상도를 1ms로 바꾸기 위해 timeBeginPeriod(1)을 사용하다.

이렇게 되면 스케쥴링을 위한 최소 타임이 Win2K인경우 10ms에서 1ms으로 변경된다.

그리고 프로그램의 첨 시작에

 

  SetPriorityClass(GetCurrentProcess(),REALTIME_PRIORITY_CLASS);

 

을 삽입하여 현 프로그램에 대한 프로세서 우선순위를 최고로 올려놓는다.

다음으로 실시간을 구현하고자 하는  쓰레드를 가장 높은 우선순위로 다음과 같이 생성한다.

 

  AfxBeginThread(MyThread,this,THREAD_PRIORITY_TIME_CRITICAL);

 

이렇게 되면 일단 모든 준비는 완료된 것이다.

다음으로 쓰레드에서 반드시 해야할 일은 Sleep을 넣는것인데 이것을 넣지 않으면

윈도우는 완전히 멈춰버리고 오직 이쓰레드만 동작하게 된다.

Sleep은 다음과 같은 방법으로 넣는다.  예를들어 100Hz로 정확하게 구동하는 쓰레드를

만들고 싶다면 다음과 같은 구조로 짠다.

 

#define HZ  100.0

 

double pretime, ctime;

pretime = GetPrecisionTime();

 

while(flag)

{

    ctime = GetPrecisionTime();

    if (ctime-pretime >= 1.0/Hz)

   {

       pretime = ctime;

 

       // 여기에 원하는 코드를 넣는다,

 

       Sleep(7);   // <<------- 매우 중요한 부분이다.

   }

}

 

위에서 사용된 GetPrecisionTime은 timeGetTime이나 GetTickCount가 1ms단위로 리턴하는것에

비해 nanosec단위로 리턴하기때문에 아주 정확하다. 함수의 원형은 아래와 같다.

 

double GetPrecisionTime(void)
{
 LARGE_INTEGER lpFrequency;
 LARGE_INTEGER lpPerformanceCount;
 QueryPerformanceFrequency(&lpFrequency);
 QueryPerformanceCounter(&lpPerformanceCount);
 return  (double)lpPerformanceCount.QuadPart /(double)lpFrequency.QuadPart;
}

 

여기서 의문이 왜 Sleep에 7이라고 했으며 왜 내 코드를 수행하는 부분 안에 넣었을까이다.

자 그럼 의문을 하나하나 풀어가보자.

timeBeginPeriod로 1ms 설정했기 때문에 Sleep의 최소단위는 1ms이다. 우리는 반드시

Sleep(1)이상을 넣어야 하는 의무가 있는 조건이다. 그럼 매번 Sleep(1)을 호출한다면

최소 1ms는 안지켜질 확률이 매우 커진다. 우린 1ms이상의 시간을 지켜야 하는 사명이 있다.

그렇다고 Sleep을 안쓰게 되면 윈도는 멈춰버린다. 해결책은 어짜피 우린 100Hz (10ms)로만

동작하면 되고 또 우리의 코드가 그리많은 시간이 필요로 하지 않는다. 아하~.. 그렇다면

우리 의 코드가 수행된다음 다음 시간까진 아직 많은 시간이 남아 있기 때문에

그때 Sleep을 주면 되겠다는 생각이 떠오른다.  그래서 시간을 측정하고 내 코드가 수행되는

그 블록안에 Sleep이 위치 해 있는것이다.

SLeep(7)은 왜했을까 ?. Sleep(1)을 하는게 생각으론 좋겠지만 그렇게 된다면 이 쓰레드 이외의

다른 작업에 겨우 1ms의 시간밖에 할당을 안해주기 때문에 전체적으로 윈도우가 느려짐을 느낄

수 있다.  그렇다고 Sleep(10)을 주면 100Hz를 달성 할 수 없다.  이것저것해보니 7~8이 가장

적당한 듯 하다. 

 

대충 의문이 풀렸는가?.. 그냥 쉽게 말해 윈도우는 평소에 아무짓도 안하고 시간만 보고 있고

시간이 되면 내작업하고 7ms동안만 윈도우의 하던 작업을 하게 시간을 내주는것이다.

 

이것이 Idea는 단순하지만 하드디스크를 억세스 하지 않는 조건이라면 아주 잘 지켜진다.

단 시험해본바로 98/2K에서만 잘되는것 같다. XP는 내부적으로 먼가 틀린가 보다.

똑같은 코드로 돌려봐도 잘 안지켜 지는것 같다.

 

아무튼..귀찮은 실시간을 조금이나마 구현하고자 하는데 도움이 됬으면 좋겠다.









http://blog.naver.com/sealriel/10145666873


[MFC 응용프로그램] 13장 멀티스레드 MFC응용프로그램

멀티스레드 MFC 응용프로그램 만들기!

 

Multi-thread programming

윈도우 프로그래밍의 특징

  • GUI(창을 통해 처리한다)
  • 멀티 태스킹이 전제된다.(메시지 드리븐(구동) 구조이다) 그 이전에는 프로그램 드리븐 구조였다.
  • 메시지 드리븐이 개발하게 된게 멀티스레딩 개념 때문에 나왔다.

 

01멀티스레드 기초

  • 멀티태스킹과 멀티스레딩
    • 멀티태스킹
      • 운영체제가 여러 개의 프로세스를 동시에 실행한다.(운영체제 개발자가 신경 써야할 부분이다.)
    • 멀티스레딩
      • 응용 프로그램이 여러 개의 스레드를 동시에 실행한다.(응용프로그램 개발자가 신경써야 할 부분이다.)
      • 하나의 프로세스 안에 여러 개의 실행흐름(스레드)를 가진다.
  1. 프로세스와 스레드.

프로세스

  • 실행 중인 프로그램(도스와 윈도우 프로세스 개념이 다르다.)
  • 도스 유닉스의 프로세스 개념은 지금의 스레드와 비슷하다.
  • 실행흐름이 아니고 정적인 개념으로 구성요소들 환경들의 집합을 프로세스라고 한다.
  • 어떤 스레드들의 공간을 제공해주는 개념이다.

프로세스 구성 요소

  • 가상 주소 공간 : 32비트 윈도우의 경우 4GB(사용자 영역 2GB+커널 영역 2GB)
  • 실행 파일과 DLL : 코드, 리소스, 데이터(전역 변수, 정적 변수)
  • 힙 / 환경 변수 / 하나 이상의 스레드 : 프로세스 커널 객체
  • 운영체제가 프로세스를 위해 할당한 각종 자원 : 파일, 소켓, ...

 

  • 프로세스 구성 요소 (cont'd)

 

 

  • 스레드
    • 프로세스의 가상 주소 공간에 존재하는 하나의 실행 흐름
    • 운영체제는 각 스레드에 CPU 시간을 나누어 할당함으로써 여러 개의 스레드가 동시에 실행되는 효과를 냄
    • 윈도우가 등장하면서 나온 개념이다.
    • 동적으로 시간의 흐름에 따라 바뀌는 것이다.
    • 스레드 : 실행의 단위 Process->프로그램이 프로세스가 된다.
    • 실행의 단위(최소)가 스레드다.
    • main(){ } 종료되면 프로세스가 종료되는 것이다.
    • CPU 연산 (코어가 하나있다고 가정하면) CPU 스케쥴링 (실행의 최소단위가 Thread가 된다)
    • Process <- 자원이 할당된다
    • Thread – 프로세스에 할당된 자원을 공유한다.

각각의 스레드별로 각각의 스택 객체가 생성되고 그 안에서 지역변수가 관리된다.

 

  • 스레드 구성 요소
    • 스택
      • 함수 인자 전달과 지역 변수 저장을 위한 공간
    • 스레드 지역 저장소(TLS, Thread Local Storage)
      • 스레드별 고유 데이터를 저장하기 위한 공간
    • 스레드 커널 객체

 

프로세스와 스레드 구성요소

 

 

  1. CPU 스케줄링

    운영체제가 한정된 CPU 시간을 여러 개의 프로세스(전통적인 유닉스 운영체제) 혹은 스레드(윈도우 운영체제)에 분배하는 정책

  • 윈도우의 CPU 스케줄링
    • 우선순위(Priority)에 기반한 CPU 스케줄링 기법을 사용
      • 우선순위가 높은 스레드에 CPU 시간을 우선 할당
  • 스레드의 우선순위 결정 요소
    • 우선순위 클래스(Priority Class)
    • 우선순위 레벨(Priority Level)
  • 우선순위 클래스
    • 프로세스 속성(프로세스한테 매겨지는 우선순위 클래스)
    • 같은 프로세스가 생성한 스레드는 모두 동일한 우선순위 클래스를 가짐
    •  이것이 우선 순위 클래스
  • 우선순위 클래스 종류
    • REALTIME_PRIORITY_CLASS(실시간)
    • HIGH_PRIORITY_CLASS(높음)
    • ABOVE_NORMAL_PRIORITY_CLASS(보통 초과; 윈도우2000/XP 이상)
    • NORMAL_PRIORITY_CLASS(보통)
    • BELOW_NORMAL_PRIORITY_CLASS(보통 미만; 윈도우2000/XP 이상)
    • IDLE_PRIORITY_CLASS(낮음)
  • 우선순위 레벨
    • 스레드 속성
    • 같은 프로세스에 속한 스레드 간 상대적인 우선순위를 결정
  • 우선순위 레벨 종류
    • THREAD_PRIORITY_TIME_CRITICAL
    • THREAD_PRIORITY_HIGHEST
    • THREAD_PRIORITY_ABOVE_NORMAL
    • THREAD_PRIORITY_NORMAL
    • THREAD_PRIORITY_BELOW_NORMAL
    • THREAD_PRIORITY_LOWEST
    • THREAD_PRIORITY_IDLE
  • 우선순위 클래스 + 우선순위 레벨
    ð 스레드의 기본 우선순위(Base Priority)

  1. 스레드 동기화 (스케줄링보다 좀더 중요함)

- 병렬 처리에서 동기화라는 것은 서로 다른 스레드 간의 실행 순서를 보장하기 위해서 구속 조건을 강제로 거는 것이다. 동시에 여러개의 쓰레드가 동일한 자료를 접근하여 조작하고 그 실행결과를 접근하는 특정 순서에 의존하는 상황을 경쟁상황이라고 부른다

즉, 동기화란 스레드의 실행순서를 구성하고 공유하는 데이터를 관리 하는것

 

02 MFC 스레드

 

  • MFC 스레드 종류
    • 작업자 스레드(Worker Thread)
      • UI 스레드가 없는 스레드
      • 메시지 루프가 없다.
        • 화면에 보이지 않는 백그라운드 작업을 수행할 때 적합
        • 입출력을 안하면서 돌아가는 스레드.(입출력을 안하니까 메시지 루프가 필요 없다.)

          사용자가 입력할 때 백그라운드에서 돌아간다.

    • 사용자 인터페이스 스레드(User Interface Thread)
      • 두개가 동시에 돌아가고 있다고 생각하면 되는게 UI Thread
      • 오히려 이 스레드는 잘 안쓰임.
      • 별도의 메시지 루프가 있다.
        • 윈도우를 만들고 출력을 하거나 사용자의 입력을 받는 등의 작업을 별도의 스레드로 처리할 때 적합

 

 

  • 작업자 스레드

    AfxBeginThread : CWinThread 타입의 스레드 객체(Thread Object)를 동적으로 생성하고 내부적으로 스레드를 만든 후 스레드 객체의 주소값을 리턴

 

CWinThread* AfxBeginThread(

AFX_THREADPROC pfnThreadProc,

LPVOID pParam,

int nPriority = THREAD_PRIORITY_NORMAL,

UINT nStackSize = 0,

DWORD dwCreateFlags = 0,

LPSECURITY_ATTRIBUTES lpSecurityAttrs = NULL

);

 

  • pfnThreadProc: 스레드 실행 시작점이 되는 함수(=제어 함수)의 주소
    • 제어 함수 형태 ð UINT 함수명(LPVOID pParam);
  • pParam: 제어 함수 실행 시 전달할 인자(32비트 포인터형)
  • nPriority: 스레드 우선순위 레벨(이 이하는 디폴트 매개변수가 있어 안 써도 된다.)
  • nStackSize: 스레드 스택 크기
  • dwCreateFlags: 스레드 생성을 제어하는 옵션
    • 0 또는 CREATE_SUSPENDED
  • lpSecurityAttrs: 보안 설명자와 핸들 상속 정보

 

  • CWinThread 클래스의 유용한 함수들

int CWinThread::GetThreadPriority(); - 스레드 우선순위 레벨 값을 얻음

BOOL CWinThread::SetThreadPriority(int nPriority); - 스레드 우선순위 레벨 값을 변경

DWORD CWinThread::SuspendThread(); - 스레드 실행을 일시 중지

DWORD CWinThread::ResumeThread(); - 일시 중지된 스레드의 실행을 재개

 

 

  • 작업자 스레드 종료
    • 방법 1: 스레드 제어 함수가 종료 코드를 리턴
      • 0을 리턴하면 일반적으로 정상 종료를 뜻함
    • 방법 2: 스레드 제어 함수 내에서 AfxEndThread() 함수를 호출(스레드 객체를 이용하는 방법)

 

void AFXAPI AfxEndThread(UINT nExitCode, BOOL bDelete=TRUE);

  • nExitCode: 스레드 종료 코드
  • bDelete: 스레드 객체를 제거할 것인지를 나타냄
    • FALSE를 사용하면 스레드 객체 재사용 가능

 

 

  • UI 스레드

UI 스레드는 작업자 스레드와 달리 메시지 루프가 있어서 사용자 입력이나 기타 메시지 형태로 전달되는 이벤트를 처리할 수 있다. 대표예는 응용 프로그램 객체이다.

 

UI 스레드 생성 절차

  • CWinThread 클래스를 상속받아 새로운 클래스를 생성
  • 클래스 선언부와 구현부에 각각 DECLARE_DYNCREATE, IMPLEMENT_DYNCREATE 매크로를 선언
  • CWinThread 클래스가 제공하는 가상 함수 중 일부를 재정의
    • CWinThread::InitInstance() 함수는 반드시 재정의. 나머지 함수는 필요에 따라 재정의
  • AfxBeginThread() 함수로 새로운 UI 스레드 생성

 

  • UI 스레드 생성

CWinThread* AfxBeginThread(

CRuntimeClass* pThreadClass,

int nPriority = THREAD_PRIORITY_NORMAL,

UINT nStackSize = 0,

DWORD dwCreateFlags = 0,

LPSECURITY_ATTRIBUTES lpSecurityAttrs = NULL

);

  • CWinThread 타입의 스레드 객체(Thread Object)를 동적으로 생성하고 내부적으로 스레드를 만든 후 스레드 객체의 주소값을 리턴
  • pThreadClass: 클래스 정보를 담고 있는 CRuntimeClass 구조체
    • 인자 전달 형태 ð RUNTIME_CLASS(클래스이름)
  • nPriority: 스레드 우선순위 레벨
  • nStackSize: 스레드 스택 크기
  • dwCreateFlags: 스레드 생성을 제어하는 옵션
    • 0 또는 CREATE_SUSPENDED
  • lpSecurityAttrs: 보안 설명자와 핸들 상속 정보

 

  • UI 스레드 종료
    • 방법 1: WM_QUIT 메시지를 받아서 메시지 루프가 종료
    • 방법 2: 스레드 제어 함수 내에서 AfxEndThread() 함수를 호출

 

 

03 스레드 동기화

 

  • MFC 클래스 계층도(그런게 있다 정도만 알면 됨)

 

  • 클래스 요약
    • CSyncObject
      • 스레드 동기화 클래스를 위한 공통의 인터페이스 제공
      • 순수가상함수를 포함하므로 직접 쓰진 않는다.
    • CCriticalSection, CEvent, CMutex, CSemaphore
      • 윈도우 운영체제에서 제공하는 스레드 동기화 객체(임계 영역, 이벤트, 뮤텍스, 세마포어)를 편리하고 일관되게 사용할 수 있게 해줌

 

  • 스레드 동기화 개념(꼭 알아야 함!)

스레드 동기화가 필요한 상황

  • 둘 이상의 스레드가 공유 자원에 접근하는 경우
  • 한 스레드가 작업을 완료한 후, 기다리고 있는 다른 스레드에 알려주는 경우

 

  • 스레드 동기화 원리

 

  • 임계영역
  • 용도
    • 공유 자원에 접근하는 다수의 스레드가 있을 때 오직 하나의 스레드만 접근할 수 있게 함
  • 장점
    • 속도가 빠름
  • 단점
    • 서로 다른 프로세스에 속한 스레드 간 동기화에는 원칙적으로 사용할 수 없음

사용예 : 두 스레드 중 Lock() 함수를 먼저 호출하는 쪽이 공유자원에 접근할 수 있고, 나중에 호출하는 쪽은 상대편이 Unlock()함수를 호출할 때까지 대기한다.

 

  • 뮤텍스(앞 과 똑 같은 개념)
  • 용도
    • 공유 자원에 접근하는 다수의 스레드가 있을 때 오직 하나의 스레드만 접근할 수 있게 함
      • 임계 영역과 기능이 동일
  • 장점
    • 서로 다른 프로세스에 속한 스레드 간 동기화에 사용할 수 있음
  • 단점
    • 임계 영역보다 속도가 느림

임계 영역과 달리 커널 모드로 동작한다.

 

  • 뮤텍스 생성

CMutex::CMutex(

BOOL bInitiallyOwn = FALSE,

LPCTSTR lpszName = NULL,

LPSECURITY_ATTRIBUTES lpsaAttribute = NULL

);

  • bInitiallyOwn: TRUE면 뮤텍스를 생성한 스레드가 소유자가 됨
  • lpszName: 뮤텍스에 이름을 부여함. NULL을 사용하면 이름 없는(Anonymous) 뮤텍스가 됨
  • lpsaAttribute: 보안 설명자와 핸들 상속 정보

 

  • 이벤트
  • 이벤트
    • 신호(Signaled)와 비신호(Nonsignaled) 두 개의 상태를 가진 동기화 객체
  • 용도
    • 한 스레드가 작업을 완료한 후, 기다리고 있는 다른 스레드에 알려줄 때 주로 사용
  • 이벤트 사용 절차
    • 이벤트를 비신호 상태로 생성
    • 한 스레드가 작업을 진행하고, 나머지 스레드는 이벤트에 대해 Lock() 함수를 호출함으로써 이벤트가 신호 상태가 될 때까지 대기함(Sleep)
    • 스레드가 작업을 완료한 후 이벤트를 신호 상태로 바꿈
    • 기다리고 있던 스레드 중 하나 혹은 전부가 깨어남(Wakeup)
  • 종류
    • 자동 리셋(Auto Reset)
      • 이벤트를 신호 상태로 바꾸면, 기다리는 스레드 중 하나만 깨운 후 자동으로 비신호 상태가 됨
    • 수동 리셋(Manual Reset)
      • 이벤트를 신호 상태로 바꾸면, 기다리는 스레드 전부를 깨운 후 계속 신호 상태를 유지함
      • 비신호 상태로 바꾸려면 명시적으로 함수를 호출해야 함
  • 이벤트 생성

CEvent::CEvent(

BOOL bInitiallyOwn = FALSE,

BOOL bManualReset = FALSE,

LPCTSTR lpszName = NULL,

LPSECURITY_ATTRIBUTES lpsaAttribute = NULL

);

  • bInitiallyOwn: FALSE면 비신호, TRUE면 신호 상태
  • bManualReset: FALSE면 자동 리셋, TRUE면 수동 리셋
  • lpszName: 이벤트에 이름을 부여함. NULL을 사용하면 이름 없는(Anonymous) 이벤트가 됨
  • lpsaAttribute: 보안 설명자와 핸들 상속 정보

 

  • 이벤트 상태 변경

BOOL CEvent::SetEvent(); - 이벤트를 신호 상태로 바꿈

BOOL CEvent::ResetEvent(); - 이벤트를 비신호 상태로 바꿈

 

  • 세마포어
    • 자원에 접근할 수 있는 스레드 수를 제어하는 동기화 객체
    • 가용 자원의 개수를 나타내는 리소스 카운트(Resource Count)를 유지함으로써 동시에 실행될 수 있는 스레드 수를 조절 가능

 

  • 세마포어 사용 절차
    • 세마포어를 생성. 이때 리소스 카운트를 가용 자원의 개수로 초기화
    • 자원을 사용할 스레드는 자신이 필요한 자원의 개수만큼 Lock() 함수를 호출하는데, Lock() 함수가 성공할 때마다 리소스 카운트 값은 1씩 감소.
      • 리소스 카운트가 0인 상태에서 Lock() 함수를 호출하면 해당 스레드는 대기함(Sleep)
    • 자원 사용을 마친 스레드는 자신이 사용한 자원의 개수만큼 Unlock() 함수를 호출하는데, Unlock() 함수가 성공할 때마다 리소스 카운트 값은 1씩 증가
      • 대기중인 다른 스레드가 있다면 깨어남(Wakeup)
  • 세마포어 생성

CSemaphore::CSemaphore(

LONG lInitialCount = 1,

LONG lMaxCount = 1,

LPCTSTR pstrName = NULL,

LPSECURITY_ATTRIBUTES lpsaAttributes = NULL

);

반응형

+ Recent posts