Port 모듈

Port 모듈

Port는 Erlang VM 바깥의 외부 세계와 상호작용하는 함수들을 제공해요. 포트(port)는 Erlang VM 외부의 운영체제 프로세스를 시작하고 메시지 전달로 통신하는 메커니즘입니다.

출처: Port

본문

예시(Example)

iex> port = Port.open({:spawn, "cat"}, [:binary])
iex> send(port, {self(), {:command, "hello"}})
iex> send(port, {self(), {:command, "world"}})
iex> flush()
{#Port<0.1444>, {:data, "hello"}}
{#Port<0.1444>, {:data, "world"}}
iex> send(port, {self(), :close})
:ok
iex> flush()
{#Port<0.1444>, :closed}
:ok

위 예시에서 cat 프로그램을 실행하는 새 포트를 만들었습니다. cat은 Unix 계열 운영체제에서 사용 가능한 프로그램으로, 여러 입력에서 데이터를 받아 출력에 이어 붙입니다.

포트를 만든 뒤 send/2로 메시지 형태의 명령 두 개를 보냈어요. 첫 번째 명령은 "hello"라는 바이너리 페이로드를, 두 번째는 "world"를 담고 있습니다.

그 두 메시지를 보낸 후 IEx 헬퍼 flush()를 호출해서 포트에서 받은 모든 메시지를 출력했는데, 이 경우 "hello"와 "world"를 돌려받았어요. 메시지가 바이너리인 이유는 Port.open/2에서 포트를 열 때 :binary 옵션을 넘겼기 때문입니다. 그 옵션이 없었다면 바이트 리스트가 나왔을 거예요.

모든 작업이 끝나면 포트를 닫았습니다.

Elixir는 포트로 작업하기 위한 많은 편의 기능을 제공하면서도 몇 가지 단점도 있어요. 아래에서 그걸 살펴볼게요.

메시지 API와 함수 API

포트로 작업하는 API는 두 가지예요. 위 예시처럼 메시지 전달을 통한 비동기 방식, 또는 이 모듈의 함수를 호출하는 방식입니다.

포트가 지원하는 메시지와 그에 대응하는 함수 API는 다음과 같습니다.

  • {pid, {:command, binary}} — 주어진 데이터를 포트에 보냅니다. command/3 참고.
  • {pid, :close} — 포트를 닫습니다. 이미 닫혀 있지 않다면, 포트는 자신의 버퍼를 비우고 실제로 닫힌 뒤 {port, :closed} 메시지로 응답합니다. close/1 참고.
  • {pid, {:connect, new_pid}}new_pid를 포트의 새 소유자로 설정합니다. 포트가 열리면 포트는 호출자 프로세스에 링크·연결되고, 포트와의 통신은 연결된 프로세스를 통해서만 일어나요. 이 메시지는 new_pid를 새 연결 프로세스로 만듭니다. 포트가 죽지 않았다면, 포트는 옛 소유자에게 {port, :connected}로 응답합니다. connect/2 참고.

반대로 포트는 연결된 프로세스에 다음 메시지를 보냅니다.

  • {port, {:data, data}} — 포트가 보낸 데이터
  • {port, :closed}{pid, :close} 메시지에 대한 응답
  • {port, :connected}{pid, {:connect, new_pid}} 메시지에 대한 응답
  • {:EXIT, port, reason} — 포트가 죽었을 때의 종료 신호. reason:normal이 아니면 소유자 프로세스가 종료 신호를 트랩(trap)하고 있을 때만 이 메시지를 받습니다

열기 메커니즘(Open mechanisms)

포트는 네 가지 주요 메커니즘으로 열 수 있어요.

간단히 요약하면, 아래 언급할 :spawn:spawn_executable 옵션을 선호하세요. 나머지 두 옵션 :spawn_driver:fd는 VM 내부의 고급 용도입니다. 프로그램을 실행하고 반환값만 가져오고 싶다면 System.cmd/3도 고려해 보세요.

Windows의 인자 분리와 신뢰할 수 없는 인자

Unix 시스템에서는 인자가 문자열 배열로 새 OS 프로세스에 전달되지만, Windows에서는 자식 프로세스가 파싱할 몫이에요. 일부 Windows 프로그램은 표준 C 런타임 argv 파싱과 일치하지 않는 자기들만의 규칙을 적용할 수 있습니다.

이는 .bat.com 파일을 호출할 때 특히 문제가 돼요. 이런 파일은 cmd.exe를 통해 암묵적으로 실행되는데, cmd.exe의 인자 파싱은 악의적인 입력에 취약하고 임의의 셸 명령을 실행하는 데 악용될 수 있기 때문이에요.

따라서 Windows에서 배치 파일이나 .com 애플리케이션을 실행한다면, 프로그램에 신뢰할 수 없는 입력을 인자로 넘기면 안 됩니다. 실행하려는 프로그램의 확장자(예: .exe)를 명시적으로 넘기고 그 프로그램이 실제로 배치 파일이나 .com 애플리케이션이 아닌지 다시 확인하면 실수로 실행하는 것을 피할 수 있어요.

이것은 spawnspawn_executable 모두에 영향을 미칩니다.

spawn

:spawn 튜플은 전체 호출로 실행될 바이너리를 받습니다. 예를 들어 "echo hello"를 직접 호출하는 데 쓸 수 있어요.

iex> port = Port.open({:spawn, "echo hello"}, [:binary])
iex> flush()
{#Port<0.1444>, {:data, "hello\n"}}

:spawn은 인자에서 프로그램 이름을 얻어 운영체제 $PATH 환경 변수를 뒤져 일치하는 프로그램을 찾습니다.

이상이 편리하긴 하지만, 이름이나 인자 중 하나에 공백이 있는 실행 파일은 호출할 수 없다는 뜻이에요. 그런 이유로 대부분의 경우 :spawn_executable을 실행하는 편이 낫습니다.

spawn_executable

spawn_executable은 더 제한적이고 명시적인 버전의 spawn이에요. 실행하려는 실행 파일의 완전한 파일 경로를 기대합니다. $PATH에 있다면 System.find_executable/1을 호출해서 얻을 수 있어요.

iex> path = System.find_executable("echo")
iex> port = Port.open({:spawn_executable, path}, [:binary, args: ["hello world"]])
iex> flush()
{#Port<0.1380>, {:data, "hello world\n"}}

:spawn_executable을 쓸 때 인자 리스트는 위처럼 :args 옵션으로 전달할 수 있어요. 옵션 전체 목록은 Erlang 함수 :erlang.open_port/2 문서를 참고하세요.

fd

:fd 이름 옵션은 Erlang VM이 사용하는 in·out 파일 디스크립터에 접근하게 해 줍니다. 이는 :user, :shell 프로세스 같은 런타임 시스템의 핵심 부분을 재구현할 때만 쓰게 될 거예요.

고아가 된 운영체제 프로세스(Orphan OS processes)

포트는 close/1 함수 또는 {pid, :close} 메시지로 닫을 수 있어요. 하지만 VM이 죽으면 포트가 시작한 오래 실행되는 프로그램의 stdin·stdout 채널은 닫히지만 자동으로 종료되지는 않습니다.

일부 Unix 명령줄 도구는 부모 프로세스가 종료되면 종료되지만, 모든 명령줄 애플리케이션이 그런 건 아니에요. 포트를 시작한 다음 VM을 종료하고 운영체제에서 포트 프로세스가 여전히 실행 중인지 확인하면 쉽게 확인할 수 있어요.

우리가 항상 서드파티 소프트웨어가 어떻게 종료되는지 제어할 수는 없어요. 필요하다면 한 가지 해결책으로, stdin이 닫혔는지 확인하는 스크립트로 자식 애플리케이션을 감싸는 방법이 있습니다. bash 셸에서 동작이 검증된 스크립트는 다음과 같아요.

#!/usr/bin/env bash

# Start the program in the background
exec "$@" &
pid1=$!

# Silence warnings from here on
exec >/dev/null 2>&1

# Read from stdin in the background and
# kill running program when stdin closes
exec 0<&0 $(
  while read; do :; done
  kill -KILL $pid1
) &
pid2=$!

# Clean up
wait $pid1
ret=$?
kill -KILL $pid2
exit $ret

위 프로그램은 stdin을 가로채기 때문에 stdin으로 기저 소프트웨어와 통신할 수는 없습니다(긍정적인 면으로, stdin에서 읽는 소프트웨어는 대개 stdin이 닫히면 종료됩니다).

이제 다음 대신:

Port.open(
  {:spawn_executable, "/path/to/program"},
  args: ["a", "b", "c"]
)

이렇게 호출할 수 있어요.

Port.open(
  {:spawn_executable, "/path/to/wrapper"},
  args: ["/path/to/program", "a", "b", "c"]
)

타입(Types)

@type name() ::
  {:spawn, charlist() | binary()}
  | {:spawn_driver, charlist() | binary()}
  | {:spawn_executable, :file.name_all()}
  | {:fd, non_neg_integer(), non_neg_integer()}

함수(Functions)

close(port)

@spec close(port()) :: true

port를 닫습니다. 자세한 내용은 :erlang.port_close/1를 참고하세요. 컴파일러에 의해 인라인 처리됩니다.

command(port, data, options \ [])

@spec command(port(), iodata(), [:force | :nosuspend]) :: boolean()

data를 포트 드라이버 port에 보냅니다. 자세한 내용은 :erlang.port_command/3를 참고하세요. 컴파일러에 의해 인라인 처리됩니다.

connect(port, pid)

@spec connect(port(), pid()) :: true

port 식별자를 pid와 연결합니다. 자세한 내용은 :erlang.port_connect/2를 참고하세요. 컴파일러에 의해 인라인 처리됩니다.

demonitor(monitor_ref, options \ []) (1.6.0부터)

@spec demonitor(reference(), options :: [:flush | :info]) :: boolean()

주어진 reference가 식별하는 모니터를 해제합니다. monitor_ref가 호출 프로세스가 monitor/1을 호출해서 얻은 레퍼런스라면 그 모니터링이 꺼집니다. 이미 꺼져 있다면 아무 일도 일어나지 않아요. 자세한 내용은 :erlang.demonitor/2를 참고하세요. 컴파일러에 의해 인라인 처리됩니다.

info(port)

@spec info(port()) :: keyword() | nil

port에 대한 정보를 반환합니다(포트가 닫혀 있으면 nil). 자세한 내용은 :erlang.port_info/1를 참고하세요.

info(port, spec)

@spec info(port(), atom()) :: {atom(), term()} | nil

port의 특정 필드에 대한 정보를 반환합니다(포트가 닫혀 있으면 nil). 자세한 내용은 :erlang.port_info/2를 참고하세요.

list()

@spec list() :: [port()]

현재 노드의 모든 포트 리스트를 반환합니다. 컴파일러에 의해 인라인 처리됩니다.

monitor(port) (1.6.0부터)

@spec monitor(port() | {name, node()} | name) :: reference() when name: atom()

호출 프로세스에서 주어진 port에 대한 모니터링을 시작합니다. 모니터링하는 포트 프로세스가 죽으면 모니터링 프로세스에 다음 모양의 메시지가 전달됩니다.

{:DOWN, ref, :port, object, reason}

여기서:

  • ref는 이 함수가 반환한 모니터 레퍼런스
  • object는 모니터링되는 port(포트 ID로 모니터링할 때) 또는 {name, node}(포트 이름으로 모니터링할 때)
  • reason은 종료 이유

자세한 내용은 :erlang.monitor/2를 참고하세요. 컴파일러에 의해 인라인 처리됩니다.

open(name, options)

@spec open(name(), list()) :: port()

튜플 nameoptions 리스트가 주어지면 포트를 엽니다. 위 모듈 문서에 지원되는 name 값에 대한 문서와 예시가 아래 요약과 함께 있습니다.

  • {:spawn, command} — 외부 프로그램 실행. command는 프로그램 이름과 선택적으로 공백으로 구분된 인자 리스트를 포함해야 합니다. 이름에 공백이 있는 프로그램이나 인자를 넘길 때는 다음 옵션을 쓰세요.
  • {:spawn_executable, filename} — 절대 파일 이름 filename이 주어진 실행 파일 실행. 인자는 :args 옵션으로 전달할 수 있어요.
  • {:spawn_driver, command} — 이른바 포트 드라이버(port driver)를 생성
  • {:fd, fd_in, fd_out} — VM이 연 파일 디스크립터 fd_in, fd_out에 접근

자세한 내용과 옵션 목록은 :erlang.open_port/2를 참고하세요. 컴파일러에 의해 인라인 처리됩니다.

더 알아보기