6 Mayıs 2021 Perşembe

Bash'te while Döngüsü ve Stdin'le Garip Davranışı


Merhaba. Bu yazıda yaklaşık 5 yıl önce bash betiklerimden birindeki while döngüsünde farkettiğim ilginç bir durumdan bahsedeceğim. Yakın zamanda benzer durumla tekrar karşılaştım. Neyse ki bu kez sorunu kolayca çözdüm ama bence bir yazıda incelenmeyi hak ediyor. Peki nedir bu ilginç durum?

Basit bir bash betiğini ele alalım:

# birinci örnek
while read -r line; do
    echo $line
    sleep 3
done < input.file

input.file, bir metin dosyası. Bir veya daha fazla satır içerebilir. Örneğin basitçe ls -1 > input.file komutuyla oluşturulmuş olsun. Betiğin yaptığı, bu satırları dosyadan tek tek okuyup 3 saniyede bir ekrana yazdırmak. Bu noktada bir sorun yok. Aynı işi farklı yolla yapan başka bir betik:

# ikinci örnek
cat input.file | while read line; do
    echo $line
    sleep 3
done

Kod farklı da olsa, girdisine ve çıktısına bakılırsa (black box yaklaşımıyla) aynılar. Son olarak aynı işi yapan bir kod daha:

# ücüncü örnek
for line in $(cat input.file); do
    echo $line
    sleep 3
done

Yukarıda while döngüsü kullanılmasa da, aynı girdiye aynı çıktıyı verdiği için öncekilere bir alternatif oluşturuyor.

Buraya kadar bir gariplik yok ama döngü içinde stdin'den okumaya çalıştığımda ortaya çıkacak. stdin'den read komutuyla girdi alabilirim, örneğin input.file'ı satır satır ekrana basıp, her satırdan sonra bir tuşa basılmasını bekleyeceğim. Bunun için örneklerdeki sleep komutunu read -t 10 -n 1 dummyvariable komutuyla değiştireceğim. t parametresi saniye cinsinden zaman aşımı (timeout) süresini ve n parametresi girdinin karakter sayısını tutuyor. Değişken adının önemi yok çünkü bunu kullanmayacağım. Girdi dosyam daha önce de söylediğim gibi ls -1 çıktısı ve 12 satırdan oluşuyor:

Arduino
Desktop
Documents
Downloads
Music
Pictures
Public
Templates
Videos
input.file
logs
test.sh

Betiğin çalışması, her satırda 10 saniyeden toplamda 120 saniye sürmesi gerek (herhangi bir tuşa basmazsam). Ve çalıştırdığım betik:

while read -r line; do
    echo $line
    read -t 10 -n 1 dummyvariable
done < input.file

Fakat kod, hiç beklediğim gibi çalışmadı. İlk satır dışında tüm satırların ilk karakterleri silindi ve bir saniye bile geçmeden sonlandı. Ama neden?

Betiği incelemek için read'in altına sleep 10 komutu ekleyip ikinci bir bash sekmesi açtım.

[user@host ~]$ ps aux | grep test | grep -v grep
user   12001  0.0  0.0 222344  3612 pts/1    S+   16:23   0:00 /bin/bash ./test.sh

[user@host ~]$ ls -la /proc/12001/fd
[SNIP]
lr-x------. 1 user user 64 Apr 29 16:26 0 -> /home/user/input.file
lrwx------. 1 user user 64 Apr 29 16:26 1 -> /dev/pts/1
lrwx------. 1 user user 64 Apr 29 16:26 10 -> /dev/pts/1
lrwx------. 1 user user 64 Apr 29 16:26 2 -> /dev/pts/1
lr-x------. 1 user user 64 Apr 29 16:26 255 -> /home/user/test.sh

Çalışan process'in dosya betimleyicilerine (file descriptor) baktığımda, normalde stdin olması gereken sıfırıncı betimleyici, input.file'a yönlendirilmiş ve bunu yapan aslında benim. input.file dosyasını while'a girdi olarak verdiğim anda, stdin değişti. Bu nedenle while içerisinde stdin'i artık kullanmam olanaksız.

Bu durum bir sorun veya hata değil, zaten yazının başından beri sorun kelimesini kullanmaktan kaçındım. Bu tamamen beklenen bir davranış ama diğer programlama dillerindeki alışkanlıklarımla düşündüğümde, C'de veya python'da sadece while içerisinde dosya betimleyicilerin değişmesi çok alışıldık değil.

İkinci örnekteki sleep komutunu da read ile değiştirdiğimde aynı davranış farklı biçimde de olsa karşıma çıkıyor. İlk satır normal ama diğer satırdaki kelimelerin ilk harfleri yok. Halbuki bu örnekte GÇ yönlendirme (IO redirection) yerine pipe kullanmıştım. Ama bu, arka planda stdin'in bir dosya yerine pipe'a yönlendirilmesi sonucunu doğurdu. Bunu incelemek için yine read'in altına bir sleep komutu ekleyip tekrar çalıştırdım ve dosya betimleyicilerine baktım:

[user@host ~]$ ps aux | grep test | grep -v grep
user   15635  0.0  0.0 222212  3340 pts/1    S+   19:01   0:00 /bin/bash ./test.sh
user   15637  0.0  0.0 222212  1996 pts/1    S+   19:01   0:00 /bin/bash ./test.sh

[user@host ~]$ pstree -p | grep test
 | |-konsole(10108)-+-bash(10120)---test.sh(15635)---test.sh(15637)---sleep(15668)

[user@host ~]$ ls -la /proc/15635/fd   /proc/15637/fd
/proc/15635/fd:
[SNIP]
lrwx------. 1 user user 64 Apr 29 19:03 0 -> /dev/pts/1
lrwx------. 1 user user 64 Apr 29 19:03 1 -> /dev/pts/1
lrwx------. 1 user user 64 Apr 29 19:03 2 -> /dev/pts/1
lr-x------. 1 user user 64 Apr 29 19:03 255 -> /home/user/test.sh

/proc/15637/fd:
[SNIP]
lr-x------. 1 user user 64 Apr 29 19:03 0 -> 'pipe:[289548]'
lrwx------. 1 user user 64 Apr 29 19:03 1 -> /dev/pts/1
lrwx------. 1 user user 64 Apr 29 19:03 2 -> /dev/pts/1

Buradaki temel fark, test.sh'in birden fazla kere çalışması. PID 15635, cat komutunu çalıştırıyor. Ardından spawn edilen 15637 ID'li process'te while çalışıyor ve birincinin çıktısı ikincisinin stdin'ine pipe aracılığıyla gönderiliyor. Dolayısıyla yöntem farklı olsa da temelde aynı sonucu üretiyor.

Üçüncü örnekteyse, ne pipe ne yönlendirme var. Dosya cat ile belleğe alınarak for döngüsünde işleniyor. Aşağıda görüleceği gibi betimleyiciler yerli yerinde:

[user@host ~]$ ps aux | grep test
user   15955  0.0  0.0 222212  3336 pts/1    S+   19:13   0:00 /bin/bash ./test.sh

[user@host ~]$ ls -la /proc/15955/fd
[SNIP]
lrwx------. 1 user user 64 Apr 29 19:13 0 -> /dev/pts/1
lrwx------. 1 user user 64 Apr 29 19:13 1 -> /dev/pts/1
lrwx------. 1 user user 64 Apr 29 19:13 2 -> /dev/pts/1
lr-x------. 1 user user 64 Apr 29 19:13 255 -> /home/user/test.sh

Dolayısıyla bu gibi durumlarda while yerine for kullanılması tercih edilebilir. Aşağıda hatalı çalışan ve görece somut bir örnek daha var:

cat ^testuser /etc/passwd | cut -d':' -f 1 | while read -r line; do
    sudo passwd $line
done

Örn. sistemde testuser01, ...02, ...03 adında üç kullanıcı olsun. Bu kullanıcıların şifrelerini değiştirmek isterken, birinci adımda passwd testuser01 çalışıyor, kullanıcıdan şifre sorulduğu an, ikinci satırdaki "testuser02" giriliyor ve passwd aynı şifreyi tekrar sorduğunda üçüncü testuser03 girildiğinden komut başarısız oluyor ve döngü tamamlanıyor ancak passwd komutu yalnız bir kere çalışıyor.

Peki bu, while ile nasıl yapılır? Bu durumda dosya betimleyicilerin elle değiştirilmesi gerekiyor. Açıkçası bunu ben de daha önce farklı bir amaçla sadece bir kere kullandım: Read n lines at a time using Bash. Burada yapılan biraz ileri düzey ancak bash'ın ne kadar esnek olduğunu gösterdiği için çok şık. Bu arada bağlantıdaki kabul edilmiş yanıt, bu sorunun çözümü için fazla karmaşık (diğer yanıtlara bakınız) ama yukarıda sözünü ettiğim duruma uygulanabilir. Şöyle ki birinci örnekte, 0. betimleyici stdin olması gerekirken dosyaya yönlendirilmiş, 10. betimleyici stdin'e bağlanmıştı. O halde bu yanıtta anlatılan yöntemle dosyayı başka bir betimleyiciyle açarak stdin'in sıfırda kalmasını sağlarım:

exec 10< input.file
while read line <&10; do
    echo $line
    read -t 10 -n 1 dummyvariable
done

Bu betik çalışırken process'in kullandığı dosyalar şöyle:

[user@host ~]$ ps aux | grep test
user   17537  0.0  0.0 222212  3408 pts/1    S+   20:26   0:00 /bin/bash ./test.sh

[user@host ~]$ ls -la  /proc/17537/fd
[SNIP]
lrwx------. 1 user user 64 Apr 29 20:26 0 -> /dev/pts/1
lrwx------. 1 user user 64 Apr 29 20:26 1 -> /dev/pts/1
lr-x------. 1 user user 64 Apr 29 20:26 10 -> /home/user/input.file
lrwx------. 1 user user 64 Apr 29 20:26 2 -> /dev/pts/1
lr-x------. 1 user user 64 Apr 29 20:26 255 -> /home/user/test.sh

Bu şekilde tam da istediğim gibi dosya onuncu betimleyiciyle açıldı ve read komutu stdin'den diğer stream ile karıştırmadan veri okuyabildi.