손쉬운 동시성 및 내결함성: 예제가 포함된 Akka 자습서

게시 됨: 2022-03-11

도전

동시 프로그램을 작성하는 것은 어렵습니다. 스레드, 잠금, 경쟁 조건 등을 처리해야 하는 것은 오류가 발생하기 쉽고 읽기, 테스트 및 유지 관리가 어려운 코드로 이어질 수 있습니다.

따라서 많은 사람들이 멀티스레딩을 완전히 피하는 것을 선호합니다. 대신 단일 스레드 프로세스를 독점적으로 사용하고 외부 서비스(예: 데이터베이스, 대기열 등)에 의존하여 필요한 동시 또는 비동기 작업을 처리합니다. 이 접근 방식이 어떤 경우에는 합법적인 대안이기는 하지만 실행 가능한 옵션이 아닌 많은 시나리오가 있습니다. 거래, 은행 업무 또는 실시간 게임과 같은 많은 실시간 시스템은 단일 스레드 프로세스가 완료될 때까지 기다릴 여유가 없습니다(지금 답이 필요합니다!). 다른 시스템은 너무 컴퓨팅 또는 리소스 집약적이어서 코드에 병렬화를 도입하지 않고 실행하는 데 너무 많은 시간(몇 시간 또는 심지어 며칠)이 걸립니다.

상당히 일반적인 단일 스레드 접근 방식(예: Node.js 세계에서 널리 사용됨)은 이벤트 기반의 비차단 패러다임을 사용하는 것입니다. 이는 컨텍스트 전환, 잠금 및 차단을 방지하여 성능을 향상시키지만 동시에 여러 프로세서를 사용하는 문제는 해결하지 못합니다(이렇게 하려면 여러 독립 프로세스를 시작하고 조정해야 함).

그렇다면 동시 애플리케이션을 구축하기 위해 스레드, 잠금 및 경쟁 조건의 깊숙한 곳으로 여행하는 것 외에 선택의 여지가 없다는 뜻입니까?

Akka 프레임워크 덕분에 대답은 아니오입니다. 이 자습서에서는 Akka 예제를 소개하고 동시 분산 응용 프로그램의 구현을 촉진하고 단순화하는 방법을 탐구합니다.

Akka 프레임워크란 무엇입니까?

이 게시물은 Akka를 소개하고 동시 분산 응용 프로그램의 구현을 촉진하고 단순화하는 방법을 탐구합니다.

Akka는 JVM에서 고도로 동시성, 분산 및 내결함성 애플리케이션을 구축하기 위한 툴킷 및 런타임입니다. Akka는 Scala와 Java 모두에 언어 바인딩이 제공되는 Scala로 작성되었습니다.

동시성을 처리하는 Akka의 접근 방식은 Actor Model을 기반으로 합니다. 액터 기반 시스템에서 모든 것이 액터이며, 객체 지향 설계에서 모든 것이 객체인 것과 매우 유사합니다. 그러나 우리의 논의와 관련된 주요 차이점은 Actor Model은 객체 지향 모델이 아닌 동시 모델로 사용하도록 특별히 설계 및 설계되었다는 것입니다. 더 구체적으로 말하면, Scala 액터 시스템에서 액터는 순차성에 대한 전제 없이 상호 작용하고 정보를 공유합니다. 액터가 서로 정보를 공유하고 서로 작업을 수행하는 메커니즘은 메시지 전달입니다.

스레드 생성 및 스케줄링, 메시지 수신 및 발송, 경쟁 조건 및 동기화 처리와 같은 모든 복잡성은 프레임워크에서 투명하게 처리하도록 이관됩니다.

Akka는 액터와 기본 시스템 사이에 레이어를 생성하여 액터가 메시지를 처리하기만 하면 됩니다. 스레드 생성 및 예약, 메시지 수신 및 발송, 경쟁 조건 및 동기화 처리와 같은 모든 복잡성은 프레임워크에서 투명하게 처리하도록 이관됩니다.

Akka는 Reactive Manifesto를 엄격히 준수합니다. 반응형 응용 프로그램은 기존의 다중 스레드 응용 프로그램을 다음 요구 사항 중 하나 이상을 충족하는 아키텍처로 대체하는 것을 목표로 합니다.

  • 이벤트 중심. 액터를 사용하면 요청을 비동기적으로 처리하고 비차단 작업만 사용하는 코드를 작성할 수 있습니다.
  • 확장 가능. Akka에서는 메시지 전달과 위치 투명성 덕분에 코드를 수정하지 않고도 노드를 추가할 수 있습니다.
  • 탄력적. 모든 응용 프로그램은 오류가 발생하고 특정 시점에서 실패합니다. Akka는 자가 치유 시스템을 촉진하기 위해 "감독"(내결함성) 전략을 제공합니다.
  • 반응형. 오늘날의 많은 고성능 및 신속한 응답 애플리케이션은 사용자에게 빠른 피드백을 제공해야 하므로 매우 시기적절한 방식으로 이벤트에 대응해야 합니다. Akka의 비차단 메시지 기반 전략은 이를 달성하는 데 도움이 됩니다.

Akka에서 배우는 무엇입니까?

액터는 본질적으로 메시지를 수신하고 이를 처리하기 위한 조치를 취하는 객체에 불과합니다. 그것은 메시지의 소스와 분리되어 있고 그것의 유일한 책임은 수신한 메시지의 유형을 적절하게 인식하고 그에 따라 조치를 취하는 것입니다.

메시지를 수신하면 액터는 다음 작업 중 하나 이상을 수행할 수 있습니다.

  • 일부 작업 자체 실행(예: 계산 수행, 데이터 유지, 외부 웹 서비스 호출 등)
  • 메시지 또는 파생된 메시지를 다른 행위자에게 전달
  • 새 액터를 인스턴스화하고 메시지를 전달합니다.

대안으로, 행위자는 메시지를 완전히 무시하도록 선택할 수 있습니다(즉, 행동하지 않을 수 있음). 적절하다고 판단되는 경우.

액터를 구현하려면 akka.actor.Actor 트레잇을 확장하고 수신 메소드를 구현해야 합니다. 액터의 수신 메소드는 메시지가 해당 액터에게 전송될 때 (Akka에 의해) 호출됩니다. 일반적인 구현은 다음 Akka 예제와 같이 패턴 일치로 구성되어 메시지 유형을 식별하고 그에 따라 대응합니다.

 import akka.actor.Actor import akka.actor.Props import akka.event.Logging class MyActor extends Actor { def receive = { case value: String => doSomething(value) case _ => println("received unknown message") } }

패턴 일치는 메시지 처리를 위한 비교적 우아한 기술로, 콜백을 기반으로 하는 유사한 구현보다 "깨끗하고" 탐색하기 쉬운 코드를 생성하는 경향이 있습니다. 예를 들어, 단순한 HTTP 요청/응답 구현을 고려하십시오.

먼저 JavaScript에서 콜백 기반 패러다임을 사용하여 이것을 구현해 보겠습니다.

 route(url, function(request){ var query = buildQuery(request); dbCall(query, function(dbResponse){ var wsRequest = buildWebServiceRequest(dbResponse); wsCall(wsRequest, function(wsResponse) { sendReply(wsResponse); }); }); });

이제 이것을 패턴 일치 기반 구현과 비교해 보겠습니다.

 msg match { case HttpRequest(request) => { val query = buildQuery(request) dbCall(query) } case DbResponse(dbResponse) => { var wsRequest = buildWebServiceRequest(dbResponse); wsCall(dbResponse) } case WsResponse(wsResponse) => sendReply(wsResponse) }

콜백 기반 JavaScript 코드는 확실히 간결하지만 확실히 읽고 탐색하기가 더 어렵습니다. 이에 비해 패턴 일치 기반 코드는 어떤 사례가 고려되고 있으며 각각이 어떻게 처리되고 있는지 보다 즉각적으로 명확하게 보여줍니다.

액터 시스템

복잡한 문제를 가지고 재귀적으로 더 작은 하위 문제로 나누는 것은 일반적으로 건전한 문제 해결 기술입니다. 이 접근 방식은 컴퓨터 과학(단일 책임 원칙과 일치)에서 특히 유용할 수 있습니다. 그 이유는 비교적 유지 관리하기 쉬운 중복성이 거의 또는 전혀 없는 깨끗한 모듈화된 코드를 생성하는 경향이 있기 때문입니다.

행위자 기반 설계에서 이 기술을 사용하면 행위자를 행위자 시스템이라고 하는 계층 구조로 논리적으로 구성할 수 있습니다. 액터 시스템은 액터가 서로 상호 작용하는 인프라를 제공합니다.

Akka 프레임워크에서 액터 시스템이 작동하는 방식의 예.

Akka에서 액터와 통신하는 유일한 방법은 ActorRef 를 통하는 것입니다. ActorRef 는 다른 객체가 해당 액터의 내부 및 상태에 직접 액세스하거나 조작하지 못하도록 하는 액터에 대한 참조를 나타냅니다. 메시지는 다음 구문 프로토콜 중 하나를 사용하여 ActorRef 를 통해 액터에게 보낼 수 있습니다.

  • ! (“tell”) – 메시지를 보내고 즉시 반환
  • ? (“ask”) – 메시지를 보내고 가능한 응답을 나타내는 Future를 반환합니다.

각 액터에는 들어오는 메시지가 배달되는 사서함이 있습니다. 선택할 수 있는 여러 사서함 구현이 있으며 기본 구현은 FIFO입니다.

액터에는 여러 메시지를 처리하는 동안 상태를 유지하기 위한 많은 인스턴스 변수가 포함되어 있습니다. Akka는 액터의 각 인스턴스가 자체 경량 스레드에서 실행되고 메시지가 한 번에 하나씩 처리되도록 합니다. 이러한 방식으로 각 액터의 상태는 개발자가 동기화 또는 경쟁 조건에 대해 명시적으로 걱정할 필요 없이 안정적으로 유지될 수 있습니다.

각 액터는 Akka Actor API를 통해 작업을 수행하기 위해 다음과 같은 유용한 정보를 제공합니다.

  • sender : 현재 처리 중인 메시지의 발신자에 대한 ActorRef
  • context : 액터가 실행 중인 컨텍스트와 관련된 정보 및 메서드(예: 새 액터를 인스턴스화하기 위한 actorOf 메서드 포함)
  • supervisionStrategy : 오류 복구에 사용할 전략을 정의합니다.
  • self : 액터 자체에 대한 ActorRef
Akka는 액터의 각 인스턴스가 자체 경량 스레드에서 실행되고 메시지가 한 번에 하나씩 처리되도록 합니다. 이러한 방식으로 각 액터의 상태는 개발자가 동기화 또는 경쟁 조건에 대해 명시적으로 걱정할 필요 없이 안정적으로 유지될 수 있습니다.

이 자습서를 함께 묶는 데 도움이 되도록 텍스트 파일의 단어 수를 계산하는 간단한 예를 살펴보겠습니다.

Akka 예제의 목적을 위해 문제를 두 개의 하위 작업으로 분해합니다. 즉, (1) 한 줄에 있는 단어 수를 세는 "자식" 작업 및 (2) 파일의 총 단어 수를 얻기 위해 줄당 단어 수를 합산하는 "부모" 작업입니다.

부모 액터는 파일에서 각 줄을 로드한 다음 해당 줄의 단어 수를 세는 작업을 자식 액터에게 위임합니다. 자식이 완료되면 결과와 함께 부모에게 메시지를 다시 보냅니다. 부모는 단어 수(각 줄에 대해)가 포함된 메시지를 수신하고 전체 파일의 총 단어 수에 대한 카운터를 유지한 다음 완료 시 호출자에게 반환합니다.

(아래에 제공된 Akka 튜토리얼 코드 샘플은 교훈을 위한 것일 뿐이므로 반드시 모든 에지 조건, 성능 최적화 등과 관련이 있는 것은 아닙니다. 또한 아래 표시된 코드 샘플의 완전한 컴파일 가능한 버전은 다음에서 사용할 수 있습니다. 이 요지.)

먼저 자식 StringCounterActor 클래스의 샘플 구현을 살펴보겠습니다.

 case class ProcessStringMsg(string: String) case class StringProcessedMsg(words: Integer) class StringCounterActor extends Actor { def receive = { case ProcessStringMsg(string) => { val wordsInLine = string.split(" ").length sender ! StringProcessedMsg(wordsInLine) } case _ => println("Error: message not recognized") } }

이 행위자는 매우 간단한 작업을 수행합니다. ProcessStringMsg 메시지(한 줄의 텍스트 포함)를 사용하고, 지정된 줄의 단어 수를 계산하고, StringProcessedMsg 메시지를 통해 보낸 사람에게 결과를 반환합니다. 우리는 클래스를 구현하여 ! (“tell”) 메서드를 사용하여 StringProcessedMsg 메시지를 보냅니다(즉, 메시지를 보내고 즉시 반환).

자, 이제 부모 WordCounterActor 클래스에 주의를 기울이겠습니다.

 1. case class StartProcessFileMsg() 2. 3. class WordCounterActor(filename: String) extends Actor { 4. 5. private var running = false 6. private var totalLines = 0 7. private var linesProcessed = 0 8. private var result = 0 9. private var fileSender: Option[ActorRef] = None 10. 11. def receive = { 12. case StartProcessFileMsg() => { 13. if (running) { 14. // println just used for example purposes; 15. // Akka logger should be used instead 16. println("Warning: duplicate start message received") 17. } else { 18. running = true 19. fileSender = Some(sender) // save reference to process invoker 20. import scala.io.Source._ 21. fromFile(filename).getLines.foreach { line => 22. context.actorOf(Props[StringCounterActor]) ! ProcessStringMsg(line) 23. totalLines += 1 24. } 25. } 26. } 27. case StringProcessedMsg(words) => { 28. result += words 29. linesProcessed += 1 30. if (linesProcessed == totalLines) { 31. fileSender.map(_ ! result) // provide result to process invoker 32. } 33. } 34. case _ => println("message not recognized!") 35. } 36. }

여기에서 많은 일이 진행 중이므로 각각을 더 자세히 살펴보겠습니다 (다음 논의에서 참조된 줄 번호는 위의 코드 샘플을 기반으로 함) ...

먼저, 처리할 파일의 이름이 WordCounterActor 생성자(라인 3)에 전달되었음을 주목하십시오. 이것은 액터가 단일 파일을 처리하는 데만 사용된다는 것을 나타냅니다. 이것은 또한 인스턴스가 한 번만 사용되기 때문에(즉, 단일 파일을 처리하기 위해) 작업이 완료될 때 상태 변수( running , totalLines , linesProcessedresult )를 재설정할 필요를 피함으로써 개발자의 코딩 작업을 단순화합니다. 그런 다음 폐기되었습니다.

다음으로, WordCounterActor 가 두 가지 유형의 메시지를 처리하는지 관찰하십시오.

  • StartProcessFileMsg (12행)
    • WordCounterActor 를 처음 시작하는 외부 액터로부터 수신됩니다.
    • 수신되면 WordCounterActor 는 먼저 중복 요청을 수신하지 않는지 확인합니다.
    • 요청이 중복되면 WordCounterActor 는 경고를 생성하고 더 이상 수행되지 않습니다(16행).
    • 요청이 중복되지 않는 경우:
      • WordCounterActor 는 발신자에 대한 참조를 fileSender 인스턴스 변수에 저장합니다(이는 Option[Actor] ]가 아닌 Option[ActorRef] 입니다. - 9행 참조). 이 ActorRef 는 최종 StringProcessedMsg (아래에 설명된 대로 StringCounterActor 자식에서 수신됨)를 처리할 때 나중에 액세스하고 이에 응답하기 위해 필요합니다.
      • 그런 다음 WordCounterActor 는 파일을 읽고 파일의 각 행이 로드될 때 StringCounterActor 자식이 생성되고 처리할 행이 포함된 메시지가 여기에 전달됩니다(21-24행).
  • StringProcessedMsg (27행)
    • 할당된 행 처리가 완료되면 자식 StringCounterActor 에서 수신됩니다.
    • 수신되면 WordCounterActor 는 파일의 행 카운터를 증가시키고 파일의 모든 행이 처리된 경우(즉, totalLineslinesProcessed 가 동일한 경우) 최종 결과를 원래 fileSender (행 28-31)로 보냅니다.

다시 한 번, Akka에서 액터 간 통신의 유일한 메커니즘은 메시지 전달이라는 점에 유의하십시오. 메시지는 액터가 공유하는 유일한 것이며, 액터는 잠재적으로 동일한 메시지에 동시에 액세스할 수 있으므로 경쟁 조건과 예기치 않은 동작을 피하기 위해 메시지를 변경할 수 없는 것이 중요합니다.

Scala의 케이스 클래스는 패턴 일치를 통해 재귀 분해 메커니즘을 제공하는 일반 클래스입니다.

따라서 메시지는 기본적으로 변경할 수 없고 패턴 일치와 얼마나 원활하게 통합되기 때문에 케이스 클래스의 형태로 메시지를 전달하는 것이 일반적입니다.

전체 앱을 실행하는 코드 샘플로 예제를 마무리하겠습니다.

 object Sample extends App { import akka.util.Timeout import scala.concurrent.duration._ import akka.pattern.ask import akka.dispatch.ExecutionContexts._ implicit val ec = global override def main(args: Array[String]) { val system = ActorSystem("System") val actor = system.actorOf(Props(new WordCounterActor(args(0)))) implicit val timeout = Timeout(25 seconds) val future = actor ? StartProcessFileMsg() future.map { result => println("Total number of words " + result) system.shutdown } } }
동시 프로그래밍에서 "미래"는 본질적으로 아직 알려지지 않은 결과에 대한 자리 표시자 개체입니다.

이번에는 ? 메시지를 보내는 방법을 사용합니다. 이런 식으로 호출자는 반환된 Future를 사용하여 이것이 가능할 때 최종 결과를 인쇄하고 ActorSystem을 종료하여 프로그램을 종료할 수 있습니다.

Akka 내결함성 및 감독자 전략

액터 시스템에서 각 액터는 자식의 감독자입니다. 액터가 메시지 처리에 실패하면 자신과 모든 자식을 일시 중단하고 일반적으로 예외 형식으로 메시지를 감독자에게 보냅니다.

Akka에서 감독자 전략은 시스템의 내결함성 동작을 정의하기 위한 기본적이고 직접적인 메커니즘입니다.

Akka에서는 수퍼바이저가 자식들로부터 자신에게까지 침투하는 예외에 반응하고 처리하는 방식을 수퍼바이저 전략이라고 합니다. 감독자 전략은 시스템의 내결함성 동작을 정의하는 기본적이고 직접적인 메커니즘입니다.

실패를 나타내는 메시지가 감독자에게 도달하면 다음 작업 중 하나를 수행할 수 있습니다.

  • 내부 상태를 유지하면서 자식(및 해당 자식)을 재개합니다. 이 전략은 자식 상태가 오류로 인해 손상되지 않고 계속해서 올바르게 작동할 때 적용할 수 있습니다.
  • 내부 상태를 지우고 자식(및 해당 자식)을 다시 시작합니다. 이 전략은 방금 설명한 것과 반대 시나리오에서 사용할 수 있습니다. 자식 상태가 오류로 인해 손상된 경우 Future에서 사용하기 전에 상태를 재설정해야 합니다.
  • 하위(및 하위)를 영구적으로 중지합니다. 이 전략은 오류 상태가 수정 가능하지 않다고 생각되지만 수행 중인 나머지 작업을 위태롭게 하지 않는 경우에 사용할 수 있으며 실패한 자식이 없을 때 완료될 수 있습니다.
  • 스스로를 멈추고 오류를 에스컬레이션하십시오. 감독자가 실패를 처리하는 방법을 몰라서 자신의 감독자에게 문제를 에스컬레이션할 때 사용됩니다.

게다가 Actor는 실패한 자식이나 형제에게도 액션을 적용하도록 결정할 수 있습니다. 이를 위해 미리 정의된 두 가지 전략이 있습니다.

  • OneForOneStrategy : 지정된 작업을 실패한 자식에만 적용합니다.
  • AllForOneStrategy : 지정된 작업을 모든 자식에 적용합니다.

다음은 OneForOneStrategy 를 사용하는 간단한 예입니다.

 import akka.actor.OneForOneStrategy import akka.actor.SupervisorStrategy._ import scala.concurrent.duration._ override val supervisorStrategy = OneForOneStrategy() { case _: ArithmeticException => Resume case _: NullPointerException => Restart case _: IllegalArgumentException => Stop case _: Exception => Escalate }

전략을 지정하지 않으면 다음 기본 전략이 사용됩니다.

  • 액터를 초기화하는 동안 오류가 발생하거나 액터가 사망한 경우 액터가 중지됩니다.
  • 다른 종류의 예외가 있는 경우 액터가 다시 시작됩니다.

이 기본 전략의 Akka 제공 구현은 다음과 같습니다.

 final val defaultStrategy: SupervisorStrategy = { def defaultDecider: Decider = { case _: ActorInitializationException ⇒ Stop case _: ActorKilledException ⇒ Stop case _: Exception ⇒ Restart } OneForOneStrategy()(defaultDecider) }

Akka는 사용자 지정 감독자 전략의 구현을 허용하지만 Akka 설명서에서 경고하는 것처럼 잘못 구현하면 차단된 액터 시스템(즉, 영구 정지된 액터)과 같은 문제가 발생할 수 있으므로 주의해서 수행하십시오.

위치 투명도

Akka 아키텍처는 위치 투명성을 지원하므로 액터는 수신 메시지의 출처에 대해 완전히 불가지론적입니다. 메시지 발신자는 액터와 동일한 JVM 또는 별도의 JVM(동일한 노드 또는 다른 노드에서 실행)에 상주할 수 있습니다. Akka를 사용하면 이러한 각 경우를 액터(및 개발자)에게 완전히 투명한 방식으로 처리할 수 있습니다. 유일한 주의 사항은 여러 노드를 통해 전송된 메시지가 직렬화 가능해야 한다는 것입니다.

Akka 아키텍처는 위치 투명성을 지원하므로 액터는 수신 메시지의 출처에 대해 완전히 불가지론적입니다.

액터 시스템은 특수 코드 없이 분산 환경에서 실행되도록 설계되었습니다. Akka는 메시지를 보낼 노드를 지정하는 구성 파일( application.conf )만 있으면 됩니다. 다음은 구성 파일의 간단한 예입니다.

 akka { actor { provider = "akka.remote.RemoteActorRefProvider" } remote { transport = "akka.remote.netty.NettyRemoteTransport" netty { hostname = "127.0.0.1" port = 2552 } } }

몇 가지 이별 팁 ...

우리는 Akka 프레임워크가 동시성과 고성능을 달성하는 데 어떻게 도움이 되는지 보았습니다. 그러나 이 튜토리얼에서 지적했듯이 Akka의 기능을 최대한 활용하기 위해 시스템을 설계하고 구현할 때 염두에 두어야 할 몇 가지 사항이 있습니다.

  • 가능한 한 최대한 각 행위자에게 가능한 가장 작은 작업을 할당해야 합니다(이전에 논의한 대로 단일 책임 원칙에 따라).
  • 액터는 이벤트(즉, 메시지 처리)를 비동기식으로 처리해야 하고 차단해서는 안 됩니다. 그렇지 않으면 성능에 부정적인 영향을 줄 수 있는 컨텍스트 전환이 발생합니다. 구체적으로 Future에서 액터를 차단하지 않도록 차단 작업(IO 등)을 수행하는 것이 가장 좋습니다. 즉:

     case evt => blockingCall() // BAD case evt => Future { blockingCall() // GOOD }
  • 메시지를 서로 전달하는 액터는 모두 자체 스레드에서 동시에 실행되므로 메시지가 모두 변경 불가능한지 확인하십시오. 변경 가능한 메시지는 예기치 않은 동작을 초래할 수 있습니다.
  • 노드 간에 전송되는 메시지는 직렬화 가능해야 하므로 메시지가 클수록 직렬화, 전송 및 역직렬화하는 데 더 오래 걸리고 성능에 부정적인 영향을 미칠 수 있다는 점을 명심하는 것이 중요합니다.

결론

Scala로 작성된 Akka는 고도로 동시성, 분산 및 내결함성 응용 프로그램의 개발을 단순화하고 촉진하여 개발자에게 많은 복잡성을 숨깁니다. Akka의 완전한 정의를 수행하려면 이 단일 자습서보다 훨씬 더 많은 것이 필요하지만 이 소개와 예제가 더 읽고 싶어질 만큼 충분히 매력적이기를 바랍니다.

Amazon, VMWare 및 CSC는 Akka를 적극적으로 사용하는 선도 기업의 몇 가지 예입니다. 공식 Akka 웹사이트를 방문하여 더 자세히 알아보고 Akka가 프로젝트에 대한 올바른 답이 될 수 있는지 알아보십시오.