0Pricing
Linux Command Line & Bash Scripting Mastery · บทเรียน

การวิเคราะห์ประสิทธิภาพสคริปต์และการหลีกเลี่ยง subshell ที่ไม่จำเป็น

วัดเวลาทำงานของสคริปต์ และแทนที่รูปแบบที่สร้างกระบวนการจำนวนมาก เช่น การต่อ cat-grep ด้วยทางเลือกในตัว

การวิเคราะห์ประสิทธิภาพสคริปต์และการหลีกเลี่ยง subshell ที่ไม่จำเป็น เป็นบทเรียน Linux Command Line & Bash Scripting Mastery ฟรีบน CoddyKit นี่คือบทเรียนที่ 1 จากทั้งหมด 4 บทเรียน คุณสามารถอ่านบทเรียนทั้งหมดด้านล่างฟรี — จากนั้นลองปฏิบัติด้วยตัวคุณเองในเบราว์เซอร์พร้อมตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7 บทเรียนนี้เป็นส่วนหนึ่งของเส้นทางการเรียน Linux Command Line & Bash Scripting Mastery และความก้าวหน้าของคุณจะซิงค์ข้ามเว็บและแอป CoddyKit คอร์ส Linux Command Line & Bash Scripting Mastery มีบทเรียนทั้งหมด 4 บทเรียน

เหตุใดประสิทธิภาพของสคริปต์จึงสำคัญ

สคริปต์ Bash ที่ทำงานช้าทำให้เวลา CI สูญเปล่า ขัดขวางงาน cron และสร้างความหงุดหงิดให้ผู้ใช้ ความช้าส่วนใหญ่ไม่ได้เกิดจากตรรกะที่ซับซ้อน แต่เกิดจากการแยกกระบวนการโดยไม่จำเป็น เพราะทุกคำสั่งภายนอกที่เรียกใช้จะสร้างกระบวนการลูกใหม่

ในบทเรียนนี้ คุณจะได้เรียนรู้วิธี:

  • วัดว่าจริง ๆ แล้วเวลาใช้ไปกับส่วนใดด้วย time และ bash -x
  • ระบุรูปแบบต่อต้านที่แยกกระบวนการจำนวนมาก เช่น การใช้ cat โดยไม่จำเป็น
  • แทนที่คำสั่งภายนอกด้วยคำสั่งในตัวของเชลล์ที่เร็วกว่า
  • ใช้เชลล์ย่อยอย่างมีจุดประสงค์ และหลีกเลี่ยงเมื่อไม่ได้เพิ่มประโยชน์

เป้าหมายคือการเขียนสคริปต์ที่ทำงานเดิมได้ด้วยกระบวนการลูกน้อยลงและใช้เวลาตามนาฬิกาน้อยลง

การจับเวลาสคริปต์ด้วยคำสั่งในตัว time

เครื่องมือวิเคราะห์ประสิทธิภาพที่ง่ายที่สุดคือคำสั่งในตัวของเชลล์ time ใส่คำสั่งนี้ไว้หน้าคำสั่งหรือกระบวนการใด ๆ เพื่อดูค่าการวัดสามรายการ:

  • real — เวลาที่ผ่านไปตามนาฬิกา (เวลาที่คุณต้องรอจริง)
  • user — เวลา CPU ที่ใช้กับโค้ดในพื้นที่ผู้ใช้
  • sys — เวลา CPU ที่ใช้ในเคอร์เนล (การเรียกระบบ, I/O)

ช่องว่างขนาดใหญ่ระหว่าง real กับ user+sys มักหมายความว่าสคริปต์กำลังรอ I/O หรือสร้างกระบวนการลูกจำนวนมาก ให้เรียกใช้ time ครอบสคริปต์ทั้งหมดก่อน เพื่อยืนยันว่ามีปัญหาจริงก่อนปรับปรุงสิ่งใด

#!/usr/bin/env bash
# Time a whole script block
time {
  for i in $(seq 1 1000); do
    echo "line $i"
  done | grep -c "5"
}
# Output example:
# 271
# real  0m0.045s
# user  0m0.038s
# sys   0m0.012s

การติดตามการทำงานด้วย bash -x และ PS4

bash -x จะแสดงทุกคำสั่งก่อนเริ่มทำงาน ซึ่งเรียกว่าการติดตามการทำงาน ช่วยให้เห็นว่าบรรทัดใดทำงานบ่อยที่สุด และมีการเรียกโปรแกรมภายนอกมากกว่าที่คาดไว้หรือไม่

โดยค่าเริ่มต้น แต่ละบรรทัดที่ติดตามจะมี + นำหน้า คุณสามารถเพิ่มข้อมูลในส่วนต้นด้วย PS4 เพื่อใส่เวลา ทำให้การติดตามกลายเป็นเครื่องมือวิเคราะห์ประสิทธิภาพแบบเบา ๆ:

  • PS4 จะถูกขยายค่าก่อนคำสั่งที่ติดตามแต่ละคำสั่ง
  • การใส่ $EPOCHREALTIME (bash 5+) หรือ $(date +%s%N) จะให้ความละเอียดระดับนาโนวินาที
  • เปลี่ยนเส้นทาง stderr ไปยังไฟล์ แล้วประมวลผลภายหลังเพื่อค้นหาส่วนที่ทำงานช้า
#!/usr/bin/env bash
# Run with:  bash -x ./myscript.sh  2>trace.log
# Or embed tracing inside the script:
export PS4='+ [${EPOCHREALTIME}] ${BASH_SOURCE}:${LINENO}: '
set -x

slow_function() {
  local result
  result=$(cat /etc/hostname)   # fork — slow
  echo "host: $result"
}

slow_function
set +x

# trace.log now contains timestamps so you can diff
# adjacent lines to find which step took longest.

เชลล์ย่อยที่ไม่จำเป็นคืออะไร

เชลล์ย่อยคือสำเนากระบวนการเชลล์ปัจจุบันที่เป็นกระบวนการลูก โดยสร้างขึ้นจาก:

  • การแทนที่คำสั่ง: $(command)
  • การจัดกลุ่มด้วยวงเล็บ: ( commands )
  • การส่งข้อมูลเข้าโครงสร้างของเชลล์ผ่านไปป์: cmd | while read ...

เชลล์ย่อยมีความจำเป็นเมื่อคุณต้องการการแยกสภาพแวดล้อมหรือกระบวนการจริง ๆ แต่จะกลายเป็นสิ่งไม่จำเป็นเมื่อใช้เพียงเพื่อเรียกโปรแกรมภายนอกที่เชลล์จัดการเองได้ หรือครอบคำสั่งในตัวด้วยการแยกกระบวนการเพิ่มโดยไม่มีเหตุผล

การแยกเชลล์ย่อยแต่ละครั้งมีต้นทุนประมาณ 1–5 มิลลิวินาทีบนระบบ Linux สมัยใหม่ หากอยู่ในลูปที่ทำงาน 10,000 ครั้ง เชลล์ย่อยที่ไม่จำเป็น 1,000 ครั้งจะเพิ่มเวลาเหนือศีรษะล้วน ๆ อีก 1–5 วินาที

รูปแบบต่อต้านแบบคลาสสิก: การใช้ cat โดยไม่จำเป็น

cat file | grep pattern เป็นรูปแบบต่อต้านที่สร้างการแยกกระบวนการจำนวนมากซึ่งมีชื่อเสียงที่สุด โดยสร้างสองกระบวนการ (cat + grep) เชื่อมต่อกันด้วยไปป์ ทั้งที่ grep เพียงคำสั่งเดียวก็อ่านไฟล์โดยตรงได้

วิธีแก้เรียบง่าย: ส่งชื่อไฟล์โดยตรงให้คำสั่งที่รองรับการอ่านไฟล์ หากเครื่องมือไม่รับชื่อไฟล์ วิธีนี้เรียกว่าการเปลี่ยนเส้นทางข้อมูลนำเข้า แต่หากรับชื่อไฟล์ ก็เพียงไม่ต้องใช้ cat

  • ช้า: cat file | grep pattern — 2 กระบวนการ, 1 ไปป์
  • เร็ว: grep pattern file — 1 กระบวนการ, ไม่มีไปป์
  • เร็วเช่นกัน: grep pattern < file — 1 กระบวนการ, เปลี่ยนเส้นทาง stdin (ไม่มีบัฟเฟอร์ไปป์)
#!/usr/bin/env bash
# Create a sample file
seq 1 10000 > /tmp/numbers.txt

# --- Slow: useless cat ---
time cat /tmp/numbers.txt | grep -c "^5"

# --- Fast: grep reads the file directly ---
time grep -c "^5" /tmp/numbers.txt

# Both print the same count; the second is measurably faster
# because it skips the cat process and the inter-process pipe.

การแทนที่คำสั่งภายนอกด้วยคำสั่งในตัวของเชลล์

การแปลงข้อมูลแบบบรรทัดเดียวจำนวนมากมีคำสั่งในตัวที่ทำงานแทนได้และหลีกเลี่ยงการแยกกระบวนการโดยสิ้นเชิง เปรียบเทียบการแทนที่ที่ใช้กันทั่วไปเหล่านี้:

  • echo ${#var} แทน echo "$var" | wc -c — ความยาวสตริง
  • ${var^^} และ ${var,,} แทน echo "$var" | tr 'a-z' 'A-Z' — การแปลงตัวพิมพ์ (bash 4+)
  • ${var//search/replace} แทน echo "$var" | sed 's/search/replace/' — การแทนที่แบบง่าย
  • [[ "$var" =~ pattern ]] แทน echo "$var" | grep -q pattern — การจับคู่รูปแบบนิพจน์ทั่วไป
  • read -r line < file แทน line=$(head -n1 file) — การอ่านบรรทัดแรก

คำสั่งในตัวเหล่านี้ไม่มีคำสั่งใดแยกกระบวนการลูก การประหยัดต่อการเรียกหนึ่งครั้งอาจมีไม่มาก แต่จะสะสมอย่างมากภายในลูป

#!/usr/bin/env bash
sentence="hello world from bash"

# --- Fork-heavy ---
upper_slow=$(echo "$sentence" | tr 'a-z' 'A-Z')
length_slow=$(echo "$sentence" | wc -c)

# --- Builtin equivalents (zero extra processes) ---
upper_fast=${sentence^^}
length_fast=${#sentence}

echo "Slow upper : $upper_slow"
echo "Fast upper : $upper_fast"
echo "Slow length: $length_slow"
echo "Fast length: $length_fast"

การหลีกเลี่ยงเชลล์ย่อยภายในลูป

การแทนที่คำสั่งภายในลูปจะเพิ่มต้นทุนการแยกกระบวนการตามจำนวนรอบ ลูปที่ทำงาน 500 ครั้งและเรียก $(date) หนึ่งครั้งจะสร้างกระบวนการลูก 500 กระบวนการเพียงเพื่อประทับเวลา

แนวทางลดเวลาเหนือศีรษะของลูป:

  • ย้ายคำสั่งที่ไม่เปลี่ยนแปลงไปไว้นอกลูป (คำนวณครั้งเดียวแล้วนำกลับมาใช้)
  • เลือกใช้การขยายเลขคณิต $(( expr )) — เป็นคำสั่งในตัว ไม่ใช่การแยกกระบวนการ
  • ใช้ printf แทนการเรียก date เมื่อจำเป็นต้องจัดรูปแบบเท่านั้น
  • รวมการเรียกภายนอกเป็นชุด: รวบรวมข้อมูลก่อน แล้วประมวลผลครั้งเดียวนอกลูป
#!/usr/bin/env bash
# Demonstrate: compute-once vs fork-per-iteration

# Bad: $(date) forks 1000 times
time (
  for i in $(seq 1 1000); do
    ts=$(date +%s)   # fork each iteration
    echo "$i $ts" > /dev/null
  done
)

# Good: capture once, reuse
time (
  ts=$(date +%s)   # fork exactly once
  for i in $(seq 1 1000); do
    echo "$i $ts" > /dev/null
  done
)

เชลล์ย่อยของไปป์และข้อผิดพลาดเรื่องขอบเขตตัวแปร

ใน bash (ต่างจาก ksh/zsh) แต่ละคำสั่งในกระบวนการจะทำงานในเชลล์ย่อยของตนเอง ซึ่งหมายความว่าตัวแปรที่กำหนดค่าภายในไปป์จะหายไปหลังจากไปป์ทำงานเสร็จ

นี่เป็นทั้งข้อผิดพลาดด้านความถูกต้องและปัญหาด้านประสิทธิภาพ คุณอาจส่งข้อมูลเข้า while read โดยคาดว่าจะรวบรวมข้อมูลได้ แต่กลับพบว่าตัวแปรว่างเปล่าหลังจากนั้น

มีสองวิธีแก้:

  • ใช้การแทนที่กระบวนการ while read line; do ...; done < <(command) — ลูป while จะทำงานในเชลล์ปัจจุบัน ไม่ใช่เชลล์ย่อย
  • ใช้ตัวเลือก lastpipe (shopt -s lastpipe) — ทำให้ส่วนสุดท้ายของกระบวนการทำงานในเชลล์ปัจจุบัน (bash 4.2+)
#!/usr/bin/env bash
count=0

# --- Bug: count is always 0 after pipe (subshell) ---
seq 1 5 | while read -r n; do
  (( count++ ))
done
echo "After pipe   : count=$count"   # prints 0

# --- Fix 1: process substitution (no subshell for while) ---
count=0
while read -r n; do
  (( count++ ))
done < <(seq 1 5)
echo "Process sub  : count=$count"   # prints 5

# --- Fix 2: lastpipe option ---
shopt -s lastpipe
count=0
seq 1 5 | while read -r n; do
  (( count++ ))
done
echo "lastpipe     : count=$count"   # prints 5

การวัดต้นทุนเชลล์ย่อยด้วยการทดสอบประสิทธิภาพขนาดเล็ก

คุณสามารถพิสูจน์เวลาเหนือศีรษะของเชลล์ย่อยได้ง่าย ๆ ด้วยการทดสอบประสิทธิภาพขนาดเล็ก เปรียบเทียบการดำเนินการเลขคณิตผ่าน $(( )) (คำสั่งในตัว) กับการดำเนินการเดียวกันที่ส่งผ่าน expr (กระบวนการภายนอก)

ผลลัพธ์บนเครื่อง Linux ทั่วไปแสดงให้เห็นว่า การเรียก expr 10,000 ครั้งใช้เวลาประมาณ 5 วินาที ขณะที่การเรียก $(( )) จำนวนเท่ากันใช้เวลาน้อยกว่า 0.1 วินาที ซึ่งต่างกันถึง 50 เท่าแม้ให้ผลลัพธ์เหมือนกัน

รูปแบบการทดสอบนี้ยังมีประโยชน์เมื่อต้องการวัดการปรับปรุงใด ๆ โดยให้เรียกใช้ทั้งสองเวอร์ชัน N ครั้งในลูป แล้วเปรียบเทียบด้วย time

#!/usr/bin/env bash
N=500

# External command (fork per call)
time (
  x=0
  for ((i=0; i<N; i++)); do
    x=$(expr $x + 1)   # forks expr each time
  done
  echo "expr result: $x"
)

# Arithmetic builtin (no fork)
time (
  x=0
  for ((i=0; i<N; i++)); do
    (( x++ ))          # pure builtin
  done
  echo "builtin result: $x"
)

การใช้สตริงตรงเพื่อหลีกเลี่ยงไปป์ของ echo

รูปแบบที่พบบ่อยคือ echo "$var" | command เพื่อส่งตัวแปรเป็น stdin วิธีนี้แยกสองกระบวนการ (echo + command) และสร้างไปป์หนึ่งชุด สตริงตรง (<<<) ให้ผลลัพธ์เดียวกันด้วยกระบวนการเดียว โดยคำสั่งภายนอกจะอ่านข้อมูลจากบัฟเฟอร์ชั่วคราวที่เคอร์เนลจัดการ

  • grep pattern <<< "$var" — หนึ่งกระบวนการ ไม่มีไปป์
  • read -r field1 field2 <<< "$line" — แยกตัวแปรโดยไม่ใช้เครื่องมือภายนอก
  • wc -w <<< "$sentence" — นับคำจากตัวแปร

สตริงตรงมีประโยชน์เป็นพิเศษภายในลูปที่ทำงานถี่ ซึ่งการแยกกระบวนการทุกครั้งมีผล

#!/usr/bin/env bash
data="The quick brown fox"

# --- Fork-heavy: echo spawns a child ---
word_count_slow=$(echo "$data" | wc -w)
echo "Slow word count: $word_count_slow"

# --- Fast: here-string, only wc spawns ---
word_count_fast=$(wc -w <<< "$data")
echo "Fast word count: $word_count_fast"

# --- Even better: use parameter expansion (zero forks) ---
# Split into array, count elements
read -ra words <<< "$data"
echo "Zero-fork count: ${#words[@]}"

การปรับโครงสร้างในทางปฏิบัติ: ก่อนและหลัง

มาดูสคริปต์ที่สมจริง ซึ่งประมวลผลไฟล์บันทึก แล้วนำทุกสิ่งที่เรียนรู้มาใช้กัน เวอร์ชันเดิมเชื่อม cat, grep, awk และ tr ด้วยไปป์ ส่วนเวอร์ชันที่ปรับโครงสร้างแล้วลดจำนวนกระบวนการจาก 8 เหลือ 2

การเปลี่ยนแปลงสำคัญ:

  • นำ cat ออก — grep อ่านไฟล์โดยตรง
  • แทนที่ tr '[:lower:]' '[:upper:]' ด้วย ${var^^}
  • แทนที่ echo "$line" | grep -q ด้วย [[ $line =~ ]]
  • ใช้ read -r ร่วมกับการแทนที่กระบวนการ แทนลูป while ที่ส่งผ่านไปป์

หลังปรับโครงสร้างแล้ว ให้เรียกใช้ time ./script.sh อีกครั้งเพื่อยืนยันการปรับปรุง ควรวัดผลเสมอ อย่าคาดเดา

#!/usr/bin/env bash
# Create a sample log
printf 'ERROR: disk full\nINFO: started\nERROR: timeout\nINFO: done\n' \
  > /tmp/sample.log

# === BEFORE (fork-heavy) ===
time (
  cat /tmp/sample.log \
    | grep 'ERROR' \
    | while read -r line; do
        label=$(echo "$line" | tr '[:lower:]' '[:upper:]')
        echo "[ALERT] $label"
      done
)

# === AFTER (builtin-first) ===
time (
  while IFS= read -r line; do
    echo "[ALERT] ${line^^}"
  done < <(grep 'ERROR' /tmp/sample.log)
)

ตรวจสอบความรู้: ขอบเขตของเชลล์ย่อย

ทดสอบความเข้าใจเกี่ยวกับเชลล์ย่อยในไปป์ไลน์ และวิธีหลีกเลี่ยงการสูญเสียการเปลี่ยนแปลงค่าตัวแปรที่เกิดขึ้นภายในไพป์

ทบทวนบทเรียน: วัดประสิทธิภาพก่อน สร้างโพรเซสน้อยลง

ในบทเรียนนี้ คุณได้เรียนรู้วิธีระบุและกำจัดสาเหตุที่พบบ่อยที่สุดของการสร้างโพรเซสโดยไม่จำเป็นในสคริปต์ bash

ประเด็นสำคัญ:

  • ใช้ time และ bash -x ที่เพิ่มข้อมูลด้วย PS4 เพื่อวัดผลก่อนปรับปรุงประสิทธิภาพ
  • การใช้ cat อย่างไร้ประโยชน์ เป็นรูปแบบการเขียนที่ควรหลีกเลี่ยงซึ่งพบได้แพร่หลายที่สุด — ส่งชื่อไฟล์ให้คำสั่งที่รองรับชื่อไฟล์โดยตรง
  • แทนที่ echo "$var" | command ด้วย here-string (command <<< "$var") หรือคำสั่งในตัว
  • การขยายพารามิเตอร์ (${var^^}, ${var//s/r}, ${#var}) ช่วยแทนการเรียกใช้ tr, sed และ wc ได้หลายกรณี
  • เชลล์ย่อยในไปป์ไลน์กลืนการเปลี่ยนแปลงค่าตัวแปร — ให้ใช้การแทนที่โพรเซสหรือ shopt -s lastpipe
  • ย้ายการเรียกใช้คำสั่งที่ไม่เปลี่ยนแปลงออกไปนอกลูป และเลือกใช้การคำนวณด้วย $(( )) แทน expr

หลักการโดยทั่วไปคือ วัดผลก่อน แทนที่คำสั่งภายนอกด้วยคำสั่งในตัวเมื่อทำได้ และตรวจสอบการปรับปรุงด้วยการวัดผลอีกครั้ง

คำถามที่พบบ่อย

บทเรียน “การวิเคราะห์ประสิทธิภาพสคริปต์และการหลีกเลี่ยง subshell ที่ไม่จำเป็น” ฟรีหรือไม่

ใช่ — ข้อความเต็มของ “การวิเคราะห์ประสิทธิภาพสคริปต์และการหลีกเลี่ยง subshell ที่ไม่จำเป็น” ฟรีให้อ่านที่นี่บนเว็บ เพื่อปฏิบัติแบบโต้ตอบ (ตัวแก้ไขโค้ดในตัวและติวเตอร์ AI ตลอด 24/7) และปลดล็อคส่วนที่เหลือของคอร์ส Linux Command Line & Bash Scripting Mastery ให้อัปเกรดเป็น CoddyKit PRO คอร์ส Linux Command Line & Bash Scripting Mastery มีบทเรียนทั้งหมด 4 บทเรียน

คุณจะเรียนรู้อะไรในบทเรียน “การวิเคราะห์ประสิทธิภาพสคริปต์และการหลีกเลี่ยง subshell ที่ไม่จำเป็น”

วัดเวลาทำงานของสคริปต์ และแทนที่รูปแบบที่สร้างกระบวนการจำนวนมาก เช่น การต่อ cat-grep ด้วยทางเลือกในตัว คุณปฏิบัติ Linux Command Line & Bash Scripting Mastery ด้วยโค้ดที่ใช้งานได้จริงที่คุณเรียกใช้โดยตรงในเบราว์เซอร์ และติวเตอร์ AI ตลอด 24/7 ตอบคำถามของคุณขณะที่คุณไปผ่านบทเรียน

คุณต้องมีประสบการณ์ก่อนที่จะเริ่มเรียน Linux Command Line & Bash Scripting Mastery หรือไม่

ไม่จำเป็นต้องมีประสบการณ์มาก่อน Linux Command Line & Bash Scripting Mastery บน CoddyKit ออกแบบมาสำหรับผู้เริ่มต้นไปจนถึงผู้เรียนขั้นสูง คุณสามารถเริ่มต้นที่นี่หรือเริ่มจากตัวแรกและเรียนด้วยความเร็วของคุณเอง นี่คือบทเรียนที่ 1 จากทั้งหมด 4 บทเรียน

บทเรียน “การวิเคราะห์ประสิทธิภาพสคริปต์และการหลีกเลี่ยง subshell ที่ไม่จำเป็น” ใช้เวลานานแค่ไหน

บทเรียน CoddyKit ส่วนใหญ่ใช้เวลาประมาณ 5–10 นาที แต่ละบทเรียนจึงสั้นและเป็นแบบโต้ตอบ คุณสามารถก้าวหน้าอย่างต่อเนื่องและกลับมาเรียนต่อจากตรงที่เพิ่งหยุดบนเว็บและแอปได้เลย

ฉันเขียนและรันโค้ดในบทเรียน Linux Command Line & Bash Scripting Mastery นี้ได้ไหม

ได้ บทเรียน Linux Command Line & Bash Scripting Mastery ทุกบทมีตัวแก้ไขโค้ดในตัว คุณจึงเขียนและรันโค้ดจริงได้เลยในเบราว์เซอร์ และได้รับข้อเสนอแนะจาก AI ในทันที — ไม่ต้องติดตั้งในเครื่องของคุณ

บทเรียนทั้งหมดในหลักสูตรนี้

  1. การวิเคราะห์ประสิทธิภาพสคริปต์และการหลีกเลี่ยง subshell ที่ไม่จำเป็น
  2. การทำงานแบบขนานด้วย xargs -P และงานเบื้องหลัง
  3. การจัดการเวิร์กโหลดด้วย GNU parallel
  4. ไปป์ไลน์แบบสตรีมและไปป์ที่ตั้งชื่อเพื่อเพิ่มอัตรารับส่งข้อมูล
← กลับไปที่ Linux Command Line & Bash Scripting Mastery