Unknownpgr

TTY

2024-01-17 07:47:28 | English, Korean

이번에 이것저것 개발하면서 TTY에 대해 자세히 알게 되어서 내용을 정리해둡니다.

TTY

tty는 teletypewriter의 약자로, Unix 계열 운영체제에서 터미널이나 콘솔 등의 장치를 추상화한 인터페이스입니다. 좀 더 구체적으로는 그러한 인터페이스를 제공하는 디바이스 드라이버를 말합니다.

리눅스의 터미널 서브시스템은 다음과 같은 세 레이어로 이루어져 있습니다.

이중 character device interface는 이 글의 범위를 벗어나므로 생략하고, 나머지 두 레이어에 대해 살펴보겠습니다.

Line Discipline

Line discipline layer(이하 LD)은 터미널을 사용할 때 당연하게 느껴지는 다양한 기능들을 제공합니다.

Signal

이러한 기능들 중 시그널 처리에 관련된 부분은 특히 흥미롭습니다. tty 드라이버는 리눅스의 다양한 process group을 하나의 foreground process group과 나머지 background process group으로 분류합니다. 그 터미널의 foreground process group에 속한 프로세스들만이 터미널에 문자열을 출력하고 터미널로부터 입력을 받을 수 있습니다. 그리고 유저로부터 제어 문자가 입력되어 (e.g. Ctrl+C) tty 드라이버가 signal을 발생시키는 경우, 이 시그널은 foreground process group에 속한 프로세스들에게만 전달됩니다.

이러한 foreground process group은 다음과 시스템 콜을 사용하여 다룰 수 있습니다.

예를 들어서 다음과 같은 스크립트를 실행하는 경우, Ctrl+C를 누르면 오류 없이 프로세스가 종료됩니다.

import subprocess
import time
import sys
import os

print("Pgrp before command: ", os.tcgetpgrp(sys.stdout.fileno()), os.getpid())

cmd = "bash -c \"ping 1.1.1.1 -c 100\""
p = subprocess.Popen(cmd, shell=True)

print("Pgrp after command: ", os.tcgetpgrp(sys.stdout.fileno()), os.getpid())

try:
    time.sleep(99999)
except:
    pass
print(f"Exiting...")

실행 결과는 아래와 같습니다.

Pgrp before command:  287745 287745
Pgrp after command:  287745 287745
PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.
64 bytes from 1.1.1.1: icmp_seq=1 ttl=52 time=3.81 ms
64 bytes from 1.1.1.1: icmp_seq=2 ttl=52 time=3.77 ms
^C
--- 1.1.1.1 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1001ms
rtt min/avg/max/mdev = 3.765/3.786/3.808/0.021 ms
Exiting...

이는 파이썬 인터프리터, bash, ping 세 프로세스가 모두 foreground process group에 포함되기 때문입니다. Ctrl+C를 누르는 순간 모든 프로세스에 동시에 시그널이 전달되고, 파이썬 인터프리터는 exception을 무시하기 때문에 별다른 오류 표시 없이 프로세스가 종료됩니다.

그러나 다음과 같이 bash를 실행할 때 i옵션을 주게 되면 결과가 달라집니다.

# 상략
cmd = 'bash -ci "ping 1.1.1.1 -c 100"'
p = subprocess.Popen(cmd, shell=True)
time.sleep(0.1) # Wait for subprocess to start
# 하략

결과는 아래와 같습니다.

Pgrp before command:  288343 288343
PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.
64 bytes from 1.1.1.1: icmp_seq=1 ttl=52 time=5.56 ms
Pgrp after command:  288345 288343
64 bytes from 1.1.1.1: icmp_seq=2 ttl=52 time=5.26 ms
64 bytes from 1.1.1.1: icmp_seq=3 ttl=52 time=5.70 ms
^C
--- 1.1.1.1 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2002ms
rtt min/avg/max/mdev = 5.259/5.505/5.702/0.184 ms
^C^C^C^C^C^C^C^C

Ctrl+C를 눌렀더니 ping 프로세스는 종료되었지만 파이썬 인터프리터는 종료되지 않는 것을 확인할 수 있습니다. bash의 -i 옵션은 interactive shell을 실행하도록 하는 옵션이고, 이 옵션을 사용하게 되면 bash가 자기 자신을 foreground process group으로 설정하기 때문입니다. 실제로 로그를 보면 foreground process group이 바뀐 것을 확인할 수 있습니다.

이 경우 잘못하면 이 프로세스가 영원히 살아있는 경우가 발생할 수 있습니다. 보통은 프로세스를 실행하는 경우 터미널을 끄게 되면 프로세스도 함께 종료됩니다. 터미널이 종료될 때 그 자식 프로세스들에게 SIGHUP 시그널이 전달되기 때문입니다. 그러나 sudo 권한으로 이런 스크립트를 실행하게 되면 터미널의 권한보다 스크립트의 실행 권한이 더 높아집니다. 그런 경우 커널은 시그널을 전달하지 않습니다. 이때 이런 프로세스가 CPU를 많이 사용한다거나 포트를 점유하게 되면 문제가 생길 수 있습니다.

이런 경우를 방지하려면 자식 프로세스가 종료된 이후 tcsetpgrp 시스템 콜을 사용해서 다시 foreground process group을 설정해주면 됩니다.

# 상략

cmd = "bash -ci \"ping 1.1.1.1 -c 100\""
p = subprocess.Popen(cmd, shell=True)
p.wait() # Wait for subprocess to finish

# Set the terminal's foreground process group to this process's group
os.tcsetpgrp(sys.stdout.fileno(), os.getpid())

# 하략

이때 맨 처음의 스크립트에서는 p.wait() 함수를 사용하면 Ctrl+C를 눌렀을 때 오류가 발생합니다. 파이썬 인터프리터에도 시그널이 전달되었기 때문입니다. 그러나 이 경우에는 os.tcsetpgrp()를 실행하기 전까지는 파이썬 인터프리터에 시그널이 전달되지 않기 때문에 p.wait()를 사용할 수 있습니다.

그런데 실제로 이렇게 해 보면 tcsetpgrp 시스템 콜이 실행되는 순간 프로세스가 Stop상태가 되어 버립니다.

Pgrp before command:  279325 279325
PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.
64 bytes from 1.1.1.1: icmp_seq=1 ttl=52 time=4.37 ms
64 bytes from 1.1.1.1: icmp_seq=2 ttl=52 time=6.41 ms
^C
--- 1.1.1.1 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1002ms
rtt min/avg/max/mdev = 4.374/5.390/6.406/1.016 ms

[1]+  Stopped                 python3 asdf.py

이것은 정상적인 동작으로, tcsetgprp의 man page를 보면 다음과 같이 설명되어 있습니다.

If tcsetpgrp() is called by a member of a background process group in its session, and the calling process is not blocking or ignoring SIGTTOU, a SIGTTOU signal is sent to all members of this background process group.

bash 프로세스가 foreground를 가져가면서 파이썬 인터프리터가 background process group이 되었고, 그래서 SIGTTOU 시그널이 전달되면서 프로세스가 Stop된 것입니다.

fg 커맨드로 프로세스를 다시 foreground로 가져올 수도 있고, 다음과 같이 이 시그널을 무시하도록 코드를 수정할 수 있습니다. Foreground process group의 변화를 확실히 알아보기 위해 ping 대신 python command를 사용하여 bash 내부에서 foreground process group을 출력해보겠습니다.

# 상략

cmd = "bash -ci \"python3 -c 'import os; print(os.getpgrp(), os.getpid())'\""
p = subprocess.Popen(cmd, shell=True)
p.wait() # Wait for subprocess to finish

# Ignore SIGTTOU
signal.signal(signal.SIGTTOU, signal.SIG_IGN)
# Set the terminal's foreground process group to this process's group
os.tcsetpgrp(sys.stdout.fileno(), os.getpid())

print("Pgrp after command: ", os.tcgetpgrp(sys.stdout.fileno()), os.getpid())

# 하략

이때의 결과는 다음과 같습니다.

Pgrp before command:  59158 59158
59160 59160
Pgrp after command:  59158 59158
^CExiting...

PTY

다음으로 tty 드라이버의 다른 흥미로운 기능인 pty에 대해 알아보겠습니다. Pty는 Pseudo terminal의 약자로 실제 터미널 장치를 모사할 수 있는 기능을 제공합니다. pty는 실제 하드웨어가 없는 터미널, 즉 GUI상의 터미널 프로그램이나 telnet, ssh등을 구현하기 위해 사용됩니다.

실제 터미널 디바이스가 연결된 경우 그 구조는 아래와 같습니다.

실제 하드웨어 - 하드웨어 디바이스 드라이버 - tty 디바이스 드라이버 - 프로그램

마찬가지로 pty의 구조는 아래와 같습니다.

프로그램 - pty 디바이스 드라이버 - tty 디바이스 드라이버 - 프로그램

그러므로 pty는 두 개의 서로 다른 프로그램을 연결해주며, 따라서 각 프로그램에서 각각 물리 디바이스와 터미널 character device file에 대응되는 file descriptior를 하나씩 가지게 됩니다.

이때 물리 디바이스에 대응되는 쪽, 즉 일반적인 유저가 사용하는 쪽을 master, pty에 대응되는 쪽, 즉 터미널을 읽는 프로세스가 사용하는 쪽을 slave라고 부릅니다. 이러한 master-slave의 쌍을 pty pair라고 부릅니다.

pty pair의 동작은 내부적으로 LD가 적용되는 bidirectional pipe와 유사하다고 생각할 수 있습니다. 그래서 master에 쓰는 내용은 slave로 전달되고 slave에서 쓰는 내용이 master로 전달됩니다. 다만 bidirectional pipe와는 다르게 LD에서 내부 버퍼 등을 사용하여 다양한 처리를 진행한 후 전달된다는 차이점이 있습니다.

리눅스에서 이러한 pty는 devpts라는 가상 파일시스템을 통해 제공됩니다. devpts는 master를 /dev/ptmx에, slave를 /dev/pts/<n>에 연결합니다. 이때 <n>은 pty이 생성될 때마다 1씩 증가하는 숫자입니다. Slave device file은 pty pair당 하나씩 생성되며 master의 경우 /dev/ptmx라는 특별한 파일 하나만이 사용됩니다. /dev/ptmx파일은 open할 때마다 새로운 pty pair를 생성하여 그 master의 file descriptor를 반환합니다.

원래는 이 ptm descriptor를 통해 pts descriptor를 얻고, 또 권한을 설정해주는 번거로운 과정이 필요합니다. 그러나 python에서는 os.openpty라는 함수를 사용하여 편리하게 pty pair의 file descriptor를 얻을 수 있습니다.

아래는 앞서 살펴봤던 스크립트를 pty를 사용하도록 수정한 것입니다.

import subprocess
import time
import sys
import os

print("Pgrp before command: ", os.tcgetpgrp(sys.stdout.fileno()), os.getpid())

master, slave = os.openpty()

cmd = 'bash -ci "ping 1.1.1.1 -c 100"'
p = subprocess.Popen(
    cmd,
    shell=True,
    stdin=slave,
    stdout=slave,
    stderr=slave,
    close_fds=True,
)

time.sleep(1)

print("Pgrp after command: ", os.tcgetpgrp(sys.stdout.fileno()), os.getpid())

try:
    time.sleep(99999)
except:
    pass
print(f"Exiting...")

실행 결과는 아래와 같습니다.

Pgrp before command:  292251 292251
Pgrp after command:  292251 292251
^CExiting...

이전과 다르게 foreground process가 변경되지 않았습니다. 왜냐하면 stdin, stdout을 pts로 설정했기 때문에 부모 프로세스와 다른 터미널을 사용하기 때문입니다. 같은 이치로 ping 커맨드의 출력 역시 표시되지 않으며 Ctrl+C를 눌렀을 때에도 부모 프로세스에 정상적으로 시그널이 전달되어 프로세스가 종료됩니다.

출력을 확인하려면 다음과 같이 대기하는 코드를 수정하면 됩니다.

import subprocess
import time
import sys
import os

print("Pgrp before command: ", os.tcgetpgrp(sys.stdout.fileno()), os.getpid())

master, slave = os.openpty()

cmd = 'bash -ci "ping 1.1.1.1 -c 100"'
p = subprocess.Popen(
    cmd,
    shell=True,
    stdin=slave,
    stdout=slave,
    stderr=slave,
    close_fds=True,
)

time.sleep(1)

print("Pgrp after command: ", os.tcgetpgrp(sys.stdout.fileno()), os.getpid())

try:
    while True:
        data = os.read(master, 1024)
        if not data:
            break
        os.write(sys.stdout.fileno(), data)
except:
    pass
print(f"Exiting...")

실행 결과는 아래와 같습니다.

Pgrp before command:  292942 292942
Pgrp after command:  292942 292942
bash: cannot set terminal process group (292942): Inappropriate ioctl for device
bash: no job control in this shell
PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.
64 bytes from 1.1.1.1: icmp_seq=1 ttl=52 time=5.35 ms
64 bytes from 1.1.1.1: icmp_seq=2 ttl=52 time=10.2 ms
64 bytes from 1.1.1.1: icmp_seq=3 ttl=52 time=9.19 ms
64 bytes from 1.1.1.1: icmp_seq=4 ttl=52 time=32.7 ms
^CExiting...

이전 예제들과 다르게 Ctrl+C를 눌렀을 때 ping 커맨드에서 보여주는 통계 부분이 보이지 않습니다. 이전 예제들에서는 자식 프로세스가 터미널에 직접 데이터를 출력했기 때문에 부모 프로세스가 종료된 후 데이터가 출력되더라도 표시되었지만, 이 경우에는 부모 프로세스가 ptm으로부터 데이터를 읽어 출력해주기 때문에 부모 프로세스가 종료되는 즉시 데이터 출력이 끊기기 때문입니다.

bash: cannot set terminal process group ... 오류는 Popen 커맨드 내부에서 tty 디바이스 대신 pipe를 사용하여 stdin, stdout을 연결하기 때문인 것으로 보입니다. 아래와 같이 fork를 직접 사용하면 오류 없이 동일한 결과를 얻을 수 있습니다.

import sys
import os

master, slave = os.openpty()

if os.fork() == 0:
    os.close(master)
    os.setsid()
    os.dup2(slave, 0)
    os.dup2(slave, 1)
    os.dup2(slave, 2)
    os.execvp("bash", ["bash", "-c", "-i", "ping 1.1.1.1 -c 100"])

os.close(slave)

try:
    while True:
        data = os.read(master, 1024)
        if not data:
            break
        os.write(sys.stdout.fileno(), data)
except:
    pass
print(f"Exiting...")

Conclusion

이 글에서는 tty의 구조와 LD, pty에 대해 알아보았습니다.

References


- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -