관리형 제너레이터 학습

Beginner

This tutorial is from open-source community. Access the source code

소개

이 랩에서는 관리형 제너레이터에 대해 배우고, 이를 특이한 방식으로 구동하는 방법을 이해하게 됩니다. 또한 간단한 작업 스케줄러를 구축하고 제너레이터를 사용하여 네트워크 서버를 만들 것입니다.

Python 의 제너레이터 함수는 실행을 위해 외부 코드가 필요합니다. 예를 들어, 반복 제너레이터는 for 루프로 반복될 때만 실행되며, 코루틴은 send() 메서드를 호출해야 합니다. 이 랩에서는 고급 애플리케이션에서 제너레이터를 구동하는 실용적인 예제를 살펴봅니다. 이 랩에서 생성되는 파일은 multitask.pyserver.py입니다.

이것은 가이드 실험입니다. 학습과 실습을 돕기 위한 단계별 지침을 제공합니다.각 단계를 완료하고 실무 경험을 쌓기 위해 지침을 주의 깊게 따르세요. 과거 데이터에 따르면, 이것은 초급 레벨의 실험이며 완료율은 84%입니다.학습자들로부터 80%의 긍정적인 리뷰율을 받았습니다.

Python 제너레이터 이해

Python 에서 제너레이터가 무엇인지부터 살펴보겠습니다. Python 에서 제너레이터는 특별한 유형의 함수입니다. 일반 함수와는 다릅니다. 일반 함수를 호출하면 처음부터 끝까지 실행되어 단일 값을 반환합니다. 그러나 제너레이터 함수는 반복 가능한 객체인 이터레이터 (iterator) 를 반환합니다. 즉, 값을 하나씩 접근할 수 있습니다.

제너레이터는 yield 문을 사용하여 값을 반환합니다. 일반 함수처럼 모든 값을 한 번에 반환하는 대신, 제너레이터는 값을 한 번에 하나씩 반환합니다. 값을 yield 한 후, 제너레이터는 실행을 일시 중지합니다. 다음에 값을 요청하면 중단된 지점부터 실행을 재개합니다.

간단한 제너레이터 생성

이제 간단한 제너레이터를 만들어 보겠습니다. WebIDE 에서 새 파일을 만들어야 합니다. 이 파일에는 제너레이터에 대한 코드가 포함됩니다. 파일 이름을 generator_demo.py로 지정하고 /home/labex/project 디렉토리에 넣습니다. 파일에 넣어야 할 내용은 다음과 같습니다.

## Generator function that counts down from n
def countdown(n):
    print(f"Starting countdown from {n}")
    while n > 0:
        yield n
        n -= 1
    print("Countdown complete!")

## Create a generator object
counter = countdown(5)

## Drive the generator manually
print(next(counter))  ## 5
print(next(counter))  ## 4
print(next(counter))  ## 3

## Iterate through remaining values
for value in counter:
    print(value)  ## 2, 1

이 코드에서는 먼저 countdown이라는 제너레이터 함수를 정의합니다. 이 함수는 숫자 n을 인수로 받아 n에서 1 까지 카운트다운합니다. 함수 내부에서 while 루프를 사용하여 n을 감소시키고 각 값을 yield 합니다. countdown(5)를 호출하면 counter라는 제너레이터 객체가 생성됩니다.

그런 다음 next() 함수를 사용하여 제너레이터에서 값을 수동으로 가져옵니다. next(counter)를 호출할 때마다 제너레이터는 중단된 지점부터 실행을 재개하고 다음 값을 yield 합니다. 세 개의 값을 수동으로 가져온 후, for 루프를 사용하여 제너레이터의 나머지 값을 반복합니다.

이 코드를 실행하려면 터미널을 열고 다음 명령을 실행합니다.

python3 /home/labex/project/generator_demo.py

코드를 실행하면 다음과 같은 출력이 표시됩니다.

Starting countdown from 5
5
4
3
2
1
Countdown complete!

제너레이터 함수의 동작 방식을 살펴보겠습니다.

  1. 제너레이터 함수는 next(counter)를 처음 호출할 때 실행을 시작합니다. 그 전에는 함수가 정의되기만 하고 실제 카운트다운은 시작되지 않습니다.
  2. yield 문에서 일시 중지됩니다. 값을 yield 한 후, 멈추고 next()에 대한 다음 호출을 기다립니다.
  3. next()를 다시 호출하면 중단된 지점부터 계속됩니다. 예를 들어, 5 를 yield 한 후, 상태를 기억하고 n을 감소시키고 다음 값을 yield 합니다.
  4. 제너레이터 함수는 마지막 값을 yield 한 후 실행을 완료합니다. 이 경우, 1 을 yield 한 후 "Countdown complete!"를 출력합니다.

이러한 실행 일시 중지 및 재개 기능이 제너레이터를 강력하게 만듭니다. 이는 작업 스케줄링 및 비동기 프로그래밍과 같이 다른 작업의 실행을 차단하지 않고 효율적인 방식으로 여러 작업을 수행해야 하는 작업에 매우 유용합니다.

제너레이터를 사용한 작업 스케줄러 생성

프로그래밍에서 작업 스케줄러는 여러 작업을 효율적으로 관리하고 실행하는 데 도움이 되는 중요한 도구입니다. 이 섹션에서는 제너레이터를 사용하여 여러 제너레이터 함수를 동시에 실행할 수 있는 간단한 작업 스케줄러를 구축합니다. 이를 통해 제너레이터가 협력적 멀티태스킹 (cooperative multitasking) 을 수행하도록 관리하는 방법을 보여줍니다. 즉, 작업이 번갈아 가며 실행되고 실행 시간을 공유합니다.

먼저 새 파일을 만들어야 합니다. /home/labex/project 디렉토리로 이동하여 multitask.py라는 파일을 만듭니다. 이 파일에는 작업 스케줄러에 대한 코드가 포함됩니다.

## multitask.py

from collections import deque

## Task queue
tasks = deque()

## Simple task scheduler
def run():
    while tasks:
        task = tasks.popleft()  ## Get the next task
        try:
            task.send(None)     ## Resume the task
            tasks.append(task)  ## Put it back in the queue
        except StopIteration:
            print('Task done')  ## Task is complete

## Example task 1: Countdown
def countdown(n):
    while n > 0:
        print('T-minus', n)
        yield              ## Pause execution
        n -= 1

## Example task 2: Count up
def countup(n):
    x = 0
    while x < n:
        print('Up we go', x)
        yield              ## Pause execution
        x += 1

이제 이 작업 스케줄러가 어떻게 작동하는지 자세히 살펴보겠습니다.

  1. deque (double-ended queue, 양방향 큐) 를 사용하여 제너레이터 작업을 저장합니다. deque는 양쪽 끝에서 요소를 효율적으로 추가하고 제거할 수 있는 데이터 구조입니다. 작업 큐에 적합한 선택입니다. 왜냐하면 작업을 끝에 추가하고 앞에서 제거해야 하기 때문입니다.
  2. run() 함수는 작업 스케줄러의 핵심입니다. 큐에서 작업을 하나씩 가져옵니다.
    • send(None)을 사용하여 각 작업을 재개합니다. 이는 제너레이터에서 next()를 사용하는 것과 유사합니다. 제너레이터에게 중단된 지점부터 실행을 계속하도록 지시합니다.
    • 작업이 yield 된 후에는 큐의 끝에 다시 추가됩니다. 이렇게 하면 작업이 나중에 다시 실행될 기회를 얻게 됩니다.
    • 작업이 완료되면 (StopIteration을 발생시키면) 큐에서 제거됩니다. 이는 작업이 실행을 완료했음을 나타냅니다.
  3. 제너레이터 작업의 각 yield 문은 일시 중지 지점 역할을 합니다. 제너레이터가 yield 문에 도달하면 실행을 일시 중지하고 제어 권한을 스케줄러에 다시 반환합니다. 이를 통해 다른 작업을 실행할 수 있습니다.

이 접근 방식은 협력적 멀티태스킹을 구현합니다. 각 작업은 자발적으로 제어 권한을 스케줄러에 다시 반환하여 다른 작업을 실행할 수 있도록 합니다. 이러한 방식으로 여러 작업이 실행 시간을 공유하고 동시에 실행될 수 있습니다.

작업 스케줄러 테스트

이제 multitask.py 파일에 테스트를 추가해 보겠습니다. 이 테스트의 목적은 여러 작업을 동시에 실행하는 것입니다. 이를 동시 실행 (concurrent execution) 이라고 합니다. 동시 실행을 통해 서로 다른 작업이 마치 동시에 진행되는 것처럼 보일 수 있습니다. 단일 스레드 환경에서는 실제로 작업이 번갈아 가며 실행됩니다.

이 테스트를 수행하려면 multitask.py 파일의 끝에 다음 코드를 추가합니다.

## Test our scheduler
if __name__ == '__main__':
    ## Add tasks to the queue
    tasks.append(countdown(10))  ## Count down from 10
    tasks.append(countdown(5))   ## Count down from 5
    tasks.append(countup(20))    ## Count up to 20

    ## Run all tasks
    run()

이 코드에서는 먼저 if __name__ == '__main__':을 사용하여 스크립트가 직접 실행되는지 확인합니다. 그런 다음 세 가지 다른 작업을 tasks 큐에 추가합니다. countdown 작업은 주어진 숫자부터 카운트다운하고, countup 작업은 지정된 숫자까지 카운트업합니다. 마지막으로 run() 함수를 호출하여 이러한 작업을 실행하기 시작합니다.

코드를 추가한 후 터미널에서 다음 명령으로 실행합니다.

python3 /home/labex/project/multitask.py

코드를 실행하면 다음과 유사한 출력이 표시됩니다 (줄의 정확한 순서는 다를 수 있습니다).

T-minus 10
T-minus 5
Up we go 0
T-minus 9
T-minus 4
Up we go 1
T-minus 8
T-minus 3
Up we go 2
...

서로 다른 작업의 출력이 어떻게 섞여 있는지 확인하십시오. 이는 스케줄러가 세 가지 작업을 모두 동시에 실행하고 있음을 명확하게 나타냅니다. 작업이 yield 문에 도달할 때마다 스케줄러는 해당 작업을 일시 중지하고 다른 작업으로 전환하여 모든 작업이 시간이 지남에 따라 진행되도록 합니다.

작동 방식

스케줄러가 실행될 때 어떤 일이 발생하는지 자세히 살펴보겠습니다.

  1. 먼저 세 개의 제너레이터 작업 countdown(10), countdown(5), countup(20)을 큐에 추가합니다. 이러한 제너레이터 작업은 yield 문에서 실행을 일시 중지하고 재개할 수 있는 특수 함수입니다.
  2. 그런 다음 run() 함수가 작업을 시작합니다.
    • 큐에서 첫 번째 작업인 countdown(10)을 가져옵니다.
    • yield 문에 도달할 때까지 이 작업을 실행합니다. yield에 도달하면 "T-minus 10"을 출력합니다.
    • 그 후, countdown(10) 작업을 큐에 다시 추가하여 나중에 다시 실행할 수 있도록 합니다.
    • 다음으로 큐에서 countdown(5) 작업을 가져옵니다.
    • "T-minus 5"를 출력하면서 yield 문에 도달할 때까지 countdown(5) 작업을 실행합니다.
    • 그리고 이 프로세스가 계속됩니다...

이 주기는 모든 작업이 완료될 때까지 계속됩니다. 각 작업은 잠시 실행될 기회를 얻으므로 스레드나 콜백을 사용할 필요 없이 동시 실행의 환상을 제공합니다. 스레드는 동시성을 달성하는 더 복잡한 방법이며, 콜백은 비동기 프로그래밍에서 사용됩니다. 우리의 간단한 스케줄러는 제너레이터를 사용하여 더 간단한 방식으로 유사한 효과를 얻습니다.

제너레이터를 사용한 네트워크 서버 구축

이 섹션에서는 우리가 배운 작업 스케줄러의 개념을 확장하여 더 실용적인 것, 즉 간단한 네트워크 서버를 만들 것입니다. 이 서버는 제너레이터를 사용하여 여러 클라이언트 연결을 동시에 처리할 수 있습니다. 제너레이터는 함수가 실행을 일시 중지하고 재개할 수 있도록 하는 강력한 Python 기능으로, 블로킹 없이 여러 작업을 처리하는 데 매우 유용합니다.

먼저 /home/labex/project 디렉토리에 server.py라는 새 파일을 만들어야 합니다. 이 파일에는 네트워크 서버에 대한 코드가 포함됩니다.

## server.py

from socket import *
from select import select
from collections import deque

## Task system
tasks = deque()
recv_wait = {}   ## Map: socket -> task (for tasks waiting to receive)
send_wait = {}   ## Map: socket -> task (for tasks waiting to send)

def run():
    while any([tasks, recv_wait, send_wait]):
        ## If no active tasks, wait for I/O
        while not tasks:
            ## Wait for any socket to become ready for I/O
            can_recv, can_send, _ = select(recv_wait, send_wait, [])

            ## Add tasks waiting on readable sockets back to active queue
            for s in can_recv:
                tasks.append(recv_wait.pop(s))

            ## Add tasks waiting on writable sockets back to active queue
            for s in can_send:
                tasks.append(send_wait.pop(s))

        ## Get next task to run
        task = tasks.popleft()

        try:
            ## Resume the task
            reason, resource = task.send(None)

            ## Handle different yield reasons
            if reason == 'recv':
                ## Task is waiting to receive data
                recv_wait[resource] = task
            elif reason == 'send':
                ## Task is waiting to send data
                send_wait[resource] = task
            else:
                raise RuntimeError('Unknown yield reason %r' % reason)

        except StopIteration:
            print('Task done')

이 향상된 스케줄러는 이전 스케줄러보다 약간 더 복잡하지만 동일한 기본 아이디어를 따릅니다. 주요 차이점을 살펴보겠습니다.

  1. 작업은 이유 ('recv' 또는 'send') 와 리소스 (소켓) 를 yield 할 수 있습니다. 즉, 작업은 특정 소켓에서 데이터를 수신하거나 전송하기 위해 대기 중임을 스케줄러에게 알릴 수 있습니다.
  2. yield 이유에 따라 작업은 다른 대기 영역으로 이동합니다. 작업이 데이터를 수신하기 위해 대기 중인 경우 recv_wait 딕셔너리로 이동합니다. 데이터를 전송하기 위해 대기 중인 경우 send_wait 딕셔너리로 이동합니다.
  3. select() 함수는 어떤 소켓이 I/O 작업을 위해 준비되었는지 파악하는 데 사용됩니다. 이 함수는 recv_waitsend_wait 딕셔너리의 소켓을 확인하고 데이터를 수신하거나 전송할 준비가 된 소켓을 반환합니다.
  4. 소켓이 준비되면 관련 작업이 다시 활성 큐로 이동합니다. 이를 통해 작업은 실행을 계속하고 대기 중이던 I/O 작업을 수행할 수 있습니다.

이러한 기술을 사용하면 작업은 다른 작업의 실행을 차단하지 않고 네트워크 I/O 를 효율적으로 대기할 수 있습니다. 이렇게 하면 네트워크 서버가 더 빠르게 응답하고 여러 클라이언트 연결을 동시에 처리할 수 있습니다.

에코 서버 구현

이제 server.py 파일에 에코 서버의 구현을 추가해 보겠습니다. 에코 서버는 클라이언트로부터 수신한 모든 데이터를 그대로 다시 보내는 유형의 서버입니다. 이는 서버가 들어오는 데이터를 처리하고 클라이언트와 통신하는 방식을 이해하는 좋은 방법입니다.

server.py 파일의 끝에 다음 코드를 추가합니다. 이 코드는 에코 서버를 설정하고 클라이언트 연결을 처리합니다.

## TCP Server implementation
def tcp_server(address, handler):
    ## Create a TCP socket
    sock = socket(AF_INET, SOCK_STREAM)
    ## Set the socket option to reuse the address
    sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)
    ## Bind the socket to the given address
    sock.bind(address)
    ## Start listening for incoming connections, with a backlog of 5
    sock.listen(5)

    while True:
        ## Yield to pause the function until a client connects
        yield 'recv', sock        ## Wait for a client connection
        ## Accept a client connection
        client, addr = sock.accept()
        ## Add a new handler task for this client to the tasks list
        tasks.append(handler(client, addr))  ## Start a handler task for this client

## Echo handler - echoes back whatever the client sends
def echo_handler(client, address):
    print('Connection from', address)

    while True:
        ## Yield to pause the function until the client sends data
        yield 'recv', client      ## Wait until client sends data
        ## Receive up to 1000 bytes of data from the client
        data = client.recv(1000)

        if not data:              ## Client closed connection
            break

        ## Yield to pause the function until the client can receive data
        yield 'send', client      ## Wait until client can receive data
        ## Send the data back to the client with 'GOT:' prefix
        client.send(b'GOT:' + data)

    print('Connection closed')
    ## Close the client connection
    client.close()

## Start the server
if __name__ == '__main__':
    ## Add the tcp_server task to the tasks list
    tasks.append(tcp_server(('', 25000), echo_handler))
    ## Start the scheduler
    run()

이 코드를 단계별로 이해해 보겠습니다.

  1. tcp_server 함수:

    • 먼저 들어오는 연결을 수신 대기하기 위해 소켓을 설정합니다. 소켓은 두 머신 간의 통신을 위한 엔드포인트입니다.
    • 그런 다음 yield 'recv', sock을 사용하여 클라이언트가 연결될 때까지 함수를 일시 중지합니다. 이는 비동기적 접근 방식의 핵심 부분입니다.
    • 마지막으로 각 클라이언트 연결에 대해 새로운 핸들러 작업을 생성합니다. 이를 통해 서버는 여러 클라이언트를 동시에 처리할 수 있습니다.
  2. echo_handler 함수:

    • 클라이언트가 데이터를 보낼 때까지 대기하기 위해 'recv', client를 yield 합니다. 이는 데이터가 사용 가능할 때까지 함수를 일시 중지합니다.
    • 클라이언트에게 데이터를 다시 보낼 수 있을 때까지 대기하기 위해 'send', client를 yield 합니다. 이는 클라이언트가 데이터를 수신할 준비가 되었는지 확인합니다.
    • 연결이 클라이언트에 의해 닫힐 때까지 클라이언트 데이터를 처리합니다.
  3. 서버를 실행하면 tcp_server 작업을 큐에 추가하고 스케줄러를 시작합니다. 스케줄러는 모든 작업을 관리하고 비동기적으로 실행되도록 하는 역할을 합니다.

서버를 테스트하려면 한 터미널에서 실행합니다.

python3 /home/labex/project/server.py

서버가 실행 중임을 나타내는 메시지가 표시됩니다. 이는 서버가 이제 들어오는 연결을 수신 대기하고 있음을 의미합니다.

다른 터미널을 열고 nc (netcat) 를 사용하여 서버에 연결합니다. Netcat 은 서버에 연결하여 데이터를 보낼 수 있는 간단한 유틸리티입니다.

nc localhost 25000

이제 메시지를 입력하면 "GOT:" 접두사가 붙어 에코되는 것을 볼 수 있습니다.

Hello
GOT:Hello
World
GOT:World

nc가 설치되어 있지 않은 경우 Python 의 내장 telnetlib를 사용할 수 있습니다. Telnetlib 는 Telnet 프로토콜을 사용하여 서버에 연결할 수 있는 라이브러리입니다.

python3 -c "import telnetlib; t = telnetlib.Telnet('localhost', 25000); t.interact()"

여러 터미널 창을 열고 여러 클라이언트를 동시에 연결할 수 있습니다. 서버는 단일 스레드임에도 불구하고 모든 연결을 동시에 처리합니다. 이는 제너레이터 기반 작업 스케줄러 덕분이며, 필요에 따라 서버가 작업을 일시 중지하고 재개할 수 있습니다.

작동 방식

이 예제는 비동기 I/O 를 위한 제너레이터의 강력한 응용 프로그램을 보여줍니다.

  1. 서버는 I/O 를 기다리는 동안 블로킹될 때 yield 합니다. 즉, 데이터를 무한정 기다리는 대신 서버는 일시 중지하고 다른 작업이 실행되도록 할 수 있습니다.
  2. 스케줄러는 I/O 가 준비될 때까지 이를 대기 영역으로 이동합니다. 이는 서버가 I/O 를 기다리는 데 리소스를 낭비하지 않도록 합니다.
  3. I/O 가 완료될 때까지 다른 작업이 실행될 수 있습니다. 이를 통해 서버는 여러 작업을 동시에 처리할 수 있습니다.
  4. I/O 가 준비되면 작업은 중단된 지점부터 계속됩니다. 이는 비동기 프로그래밍의 핵심 기능입니다.

이 패턴은 Python 3.4 에서 Python 표준 라이브러리에 추가된 asyncio와 같은 최신 비동기 Python 프레임워크의 기반을 형성합니다.

요약

이 랩에서는 Python 에서 관리되는 제너레이터의 개념에 대해 배웠습니다. yield 문을 사용하여 제너레이터를 일시 중지하고 재개하는 방법을 탐구하고, 여러 제너레이터를 동시에 실행하기 위한 간단한 작업 스케줄러를 구축했습니다. 또한, 스케줄러를 확장하여 네트워크 I/O 를 효율적으로 처리하고 여러 연결을 동시에 처리할 수 있는 네트워크 서버를 구현했습니다.

협력적 멀티태스킹 (cooperative multitasking) 을 위해 제너레이터를 사용하는 이 패턴은 내장된 asyncio 모듈과 같은 Python 의 많은 비동기 프로그래밍 프레임워크의 기반이 되는 강력한 기술입니다. 이 접근 방식은 간단한 순차적 코드, 효율적인 논블로킹 I/O 처리, 여러 스레드 없이 협력적 멀티태스킹, 작업 실행에 대한 세밀한 제어 등 여러 가지 이점을 제공합니다. 이러한 기술은 고성능 네트워크 애플리케이션과 동시 작업의 효율적인 처리가 필요한 시스템을 구축하는 데 유용합니다.