ข้าม​ไป​ยัง​เนื้อหา

version chain กับ​กฎ​ที่​ตัดสิน​ว่า​ใคร​เห็น​อะไร

คีย์ order:total ใน​ที่​เก็บ​นี้​ไม่​ได้​มี​ค่า​เดียว มัน​มี​ทั้ง 100 และ 250 อยู่​พร้อม​กัน

ถ้า​ถาม transaction สอง​ตัว​ที่​เปิด​ค้าง​อยู่​ว่า​คีย์​นี้​เท่าไร คุณ​จะ​ได้​คนละ​คำ​ตอบ

ทั้ง​สอง​คำ​ตอบ​ถูก ประโยค​นี้​คือ​ใจกลาง​ของ MVCCMultiversion Concurrency Control (MVCC)การ​เก็บ​ค่า​เดิม​ไว้​หลาย version แทน​การ​เขียน​ทับ ผู้​อ่าน​จึง​อ่าน version ที่​ตัวเอง​ควร​เห็น​ได้​โดย​ไม่​ต้อง​รอ​ผู้​เขียน และ​ผู้​เขียน​ไม่​ต้อง​รอ​ผู้​อ่าน และ​มัน​ไม่ใช่​การ​เล่นคำ

บท​ที่ 1 เปิด​โครง​ของ version กับ​กฎ​การ​มอง​เห็น​ให้​ดู​อย่าง​ละ​ครั้ง บท​นี้​ลง​ไป​ดู​ว่า​สอง​อย่าง​นั้น ทำงาน​ร่วม​กัน​อย่างไร เพราะ​อีก​สี่บท​ที่​เหลือ​ยืน​อยู่​บน​กลไก​นี้​ทั้งหมด

📦 รัน​บท​นี้​เอง

คำ​สั่ง​ของ​บท​นี้​คือ cargo run --quiet -- 04 สั่ง​จาก scripts/transaction-engine/

ผล​รัน​ทุก​บรรทัด​ที่​ยก​มา​ข้าง​ล่าง​คัด​มา​ที​ละ byte จาก expected/04-mvcc-versions-and-visibility.txt ซึ่ง​เป็น​ผล​ของ​คำ​สั่ง​นั้น​ที่​ตรึง​ไว้​แล้ว และ​มี​เทสต์คอย​เทียบ​ให้​ทุก​ครั้ง​ที่ CI รัน

หัวข้อ​นี้​แสดง​ว่าที่​เก็บ​ผูก​หนึ่ง​คีย์​ไว้​กับ​รายการ​ของ​หลาย version และ​อ่าน​ป้าย begin กับ end ของ​แต่ละ​ตัว

ที่​เก็บ​ของ​เรา​ไม่มี​ช่อง​ไหน​เก็บ “ค่า​ปัจจุบัน” ของ​คีย์​เลย​สัก​ช่อง

src/store.rs · หนึ่ง​คีย์​ผูก​กับ​รายการ​ของ​หลาย version
/// ที่เก็บทั้งหมด รวมทั้งทะเบียนว่า transaction ไหน commit ไปแล้วบ้าง
#[derive(Debug, Default)]
pub struct Store {
pub versions: BTreeMap<String, Vec<Version>>,
pub committed: BTreeSet<Xid>,
pub next_xid: Xid,
pub aborts: u32,
}

ชนิด​ของ​ค่า​คือ Vec<Version> ไม่ใช่ Version รายการ​นี้​เรียง​จาก​เก่า​ไป​ใหม่​ตาม​ลำดับ​ที่​ถูก​ต่อ​เข้า​มา และ​มัน​คือ​สิ่ง​ที่​เรียก​ว่า Version ChainVersion Chainลำดับ​ของ​ทุก version ของ​แถว​เดียวกัน เรียง​จาก​เก่า​ไป​ใหม่​ตาม​ลำดับ​ที่​ถูก​ต่อ​เข้า​มา พร้อม​ข้อมูล​กำกับ​ว่า transaction ไหน​สร้าง​และ transaction ไหน​ลบ version นั้น

เอนจินพิมพ์ chain จริง​ออก​มา​ให้​ดู​ได้​ทั้ง​เส้น

cargo run --quiet -- 04
== บทที่ 4 version chain กับกฎการมองเห็น ==
chain ของ order:total
begin=1 end=3 value=100
begin=3 end=- value=250

สอง​บรรทัด​ล่าง​คือ chain ทั้ง​เส้น ตัว​บน​ถูก​สร้าง​โดย transaction หมายเลข 1 และ​ถูก​ปิด​โดย​หมายเลข 3 ส่วนตัว​ล่าง​ถูก​สร้าง​โดย​หมายเลข 3 และ​ยัง​ไม่มี​ใคร​ปิด

end=- คือ​การ​พิมพ์​แทน end == None มัน​แปล​ว่า version นี้​ยัง​เป็น​ตัว​ท้าย​สุด​ของ chain อยู่

สังเกต​ว่า​ไม่มี​บรรทัด​ไหน​หาย​ไป​ตอน​มี​คน​แก้​ค่า การ​แก้​ค่า​จาก 100 เป็น 250 เพิ่ม​บรรทัด ไม่​ได้​ทับ​บรรทัด​เดิม

หัวข้อ​นี้​แสดง​ว่า transaction สอง​ตัว​ได้​คนละ​ค่า​จาก​คีย์​เดียวกัน แล้ว​อธิบาย​ว่า​อะไร​ทำให้​คำ​ตอบ​ทั้ง​คู่​ถูก

cargo run --quiet -- 04
transaction ที่เปิดก่อน bump เห็น 100
transaction ที่เปิดหลัง bump เห็น 250
ทั้งสองอ่านคีย์เดียวกัน ณ เวลาเดียวกัน และทั้งคู่ถูก

ตัว​ที่​เปิด​ก่อน​จับ snapshot ไว้​ตอน​ที่​ยัง​ไม่มี​ใคร commit ค่า 250 ใน​ภาพ​ของ​มัน​จึง​มี​แค่​หมายเลข 1

ตัว​ที่​เปิด​ทีหลัง​จับ​ภาพ​หลัง​จาก​นั้น หมายเลข 3 จึง​ติด​มา​ด้วย และ​มัน​เห็น 250

จุด​ที่​คน​มัก​คิด​ว่า​เป็น​ข้อ​ผิดพลาด​คือ ทั้ง​คู่​ยัง​มี​ชีวิต​อยู่​พร้อม​กัน ถาม​พร้อม​กัน​เมื่อไร​ก็ได้​คนละ​คำ​ตอบ เหมือน​เดิม​ทุก​ครั้ง

เหตุผล​อยู่​ที่ begin มัน​คัด​ลอก​ทะเบียน committed ออก​มา​เป็น​สำเนา ไม่ใช่​การ​อ้างอิง​ไป​ที่​ทะเบียน ภาพ​ที่​ตัว​แรก​ถือ​อยู่​จึง​ไม่​ขยับ ต่อ​ให้​ใคร​จะ commit อะไร​เพิ่ม​ทีหลัง

นี่​คือ​สิ่ง​ที่ Visibility RuleVisibility Ruleกฎ​ที่​ตัดสิน​ว่า transaction หนึ่ง​จะ​เห็น version ไหน​ของ​แถว​หนึ่ง โดย​ดู​จาก​หมายเลข​ของ transaction ที่​สร้าง version นั้น​และ​สถานะ​ของ​มัน​ตอน​ที่ snapshot ถูก​สร้าง ซื้อ​มา​ให้ ผู้​อ่าน​ไม่​ต้อง​รอ​ผู้​เขียน และ​ผู้​เขียน​ไม่​ต้อง​รอ​ผู้​อ่าน โดย​ไม่มี​ใคร​ต้อง​ยอมรับ​คำ​ตอบ​ที่​ผิด

flowchart LR
  V1["version ตัวแรก<br/>begin 1 · end 3 · value 100"]
  V2["version ตัวที่สอง<br/>begin 3 · end - · value 250"]
  E["early · xid 2<br/>snapshot มีแค่ 1"]
  L["late · xid 4<br/>snapshot มี 1 กับ 3"]
  V1 -->|ต่อท้าย chain| V2
  E -.->|ก้าวแรก ข้ามไป เพราะ 3 ไม่อยู่ใน snapshot| V2
  E ==>|ก้าวที่สอง หยุดตรงนี้ ได้ 100| V1
  L ==>|ก้าวแรก หยุดตรงนี้เลย ได้ 250| V2

คำ​บรรยาย​ภาพ: ผู้​อ่าน​สอง​คน​เดิน​บน chain เส้น​เดียวกัน แต่ snapshot ที่​ต่าง​กัน​ทำให้​หยุด​คนละ​ที่ · เส้น​ทึบ​คือ version ที่​ถูก​อ่าน​จริง

หัวข้อ​นี้​ตาม​รอย​ลำดับ​การ​เดิน​ของ read และ​ชี้​ว่า​ลำดับ​นั้น​ทำให้การ​แก้​ค่า​ไม่​ต้อง​พึ่ง​การ​ตรวจ end เลย

การ​เดิน chain ทั้งหมด​ของ​เอนจินอยู่​ใน​บรรทัด​สุดท้าย​ของ read บรรทัด​เดียว

src/txn.rs · การ​อ่าน​ที่​ไล่ chain จาก​ท้าย​มา​หน้า
/// อ่านค่าล่าสุดที่ *เรา* มองเห็น ไม่ใช่ค่าล่าสุดที่มีอยู่จริง
pub fn read(&mut self, store: &Store, key: &str) -> Option<Value> {
self.reads.insert(key.to_string());
let chain = store.versions.get(key)?;
chain.iter().rev().find(|v| visible(v, &self.snapshot, self.xid)).map(|v| v.value)
}

rev() กลับ​ทิศ​การ​เดิน​ให้​เริ่ม​จาก​ตัว​ใหม่​สุด ส่วน find หยุด​ทันที​ที่​เจอ​ตัว​แรก​ที่​ผ่าน​กฎ กติกา​จึง​เป็น ตัว​ใหม่​สุด​ที่​มอง​เห็น​ได้​ชนะ

ถ้า​ไม่​เจอ​สัก​ตัว find คืน None และ read ก็​คืน None ตาม​ไป​ด้วย จำ​ข้อ​นี้​ไว้ เพราะ​การ​ลบ​ทั้งหมด​จบ​ลง​ที่ None ตัว​นี้

รายละเอียด​ของ​ลำดับ​นี้​อธิบาย​เรื่อง​ที่​ค้าง​มา​จาก​บท​ที่ 1 ได้ นั่น​คือ​ทำไม​กฎ​ข้อ​ที่​สอง ซึ่ง​ตรวจ​ช่อง end ถึง​ดูเหมือน​ของ​แถม​เวลา​มี​คน​แก้​ค่า

src/txn.rs · การ​แก้​ค่า ปิด​ของ​เดิม​แล้ว​ต่อ​ของ​ใหม่
pub fn write(&mut self, store: &mut Store, key: &str, value: Value) {
self.writes.insert(key.to_string());
let snapshot = self.snapshot.clone();
let chain = store.versions.entry(key.to_string()).or_default();
for v in chain.iter_mut() {
if visible(v, &snapshot, self.xid) {
v.end = Some(self.xid);
}
}
chain.push(Version { begin: self.xid, end: None, value });
}

คน​ที่​ปิด version เดิม​กับ​คน​ที่​ต่อ version ใหม่​คือ​คน​เดียวกัน​เสมอ และ​หมายเลข​ที่​ไป​อยู่​ใน​ช่อง end ของ​ตัว​เก่า คือ​หมายเลข​เดียว​กับ​ที่​ไป​อยู่​ใน​ช่อง begin ของ​ตัว​ใหม่

ผล​ที่​ตาม​มา​คือ​กฎ​ข้อ​ที่​สอง​ไม่มี​วัน​เป็น​ตัว​ตัดสิน​ใน​กรณี​นี้ ถ้า​คุณ​เห็น​คน​ที่​ปิด แปล​ว่า​คุณ​เห็น version ใหม่​ของ​เขา​ด้วย และ​ตัว​นั้น​อยู่​ท้าย​กว่า read จึง​เจอ​มัน​ก่อน​แล้ว​หยุด

ถ้า​คุณ​ไม่​เห็น​คน​ที่​ปิด กฎ​ข้อ​ที่​สอง​ก็​ปล่อย​ผ่าน​อยู่​แล้ว ไม่​ว่า​จะ​ทาง​ไหน คำ​ตอบ​เท่า​เดิม

หัวข้อ​นี้​เทียบ delete กับ write ที​ละ​บรรทัด แล้ว​ชี้​ว่า​ความ​ต่าง​เพียง​จุด​เดียว​คือ​เหตุผล​ทั้งหมด​ของ​กฎ​ข้อ​ที่​สอง

src/txn.rs · การ​ลบ ปิด​ของ​เดิม​โดย​ไม่​ต่อ​อะไร​เลย
/// ลบคีย์ — ปิด version เดิมโดยไม่ต่อของใหม่ ผลที่ค้างไว้คือ tombstone
pub fn delete(&mut self, store: &mut Store, key: &str) {
self.writes.insert(key.to_string());
let snapshot = self.snapshot.clone();
if let Some(chain) = store.versions.get_mut(key) {
for v in chain.iter_mut() {
if visible(v, &snapshot, self.xid) {
v.end = Some(self.xid);
}
}
}
}

วน​ปิด​เหมือน write ทุก​บรรทัด แล้ว​จบ​ตรง​นั้น ไม่มี chain.push(...) ต่อ​ท้าย​ให้

อีก​จุด​ที่​ต่าง​เป็น​เรื่อง​เล็ก​กว่า write ใช้ entry ซึ่ง​สร้าง chain ให้​ถ้า​คีย์​ยัง​ไม่​เคย​มี ส่วน delete ใช้ get_mut ซึ่ง​ไม่​ทำ​อะไร​เลย​กับ​คีย์​ที่​ไม่มี​อยู่

สิ่ง​ที่​ค้าง​อยู่​ใน chain หลัง​การ​ลบ​คือ version ที่​มี​ป้าย end ติด​อยู่​โดย​ไม่มี​ตัว​ใหม่​ตาม​มา ป้าย​ใบ​นั้น​คือ TombstoneTombstoneเครื่องหมาย​ที่​เขียน​เพิ่ม​เข้าไป​เพื่อ​บอกว่า​แถว​นี้​ถูกลบ​แล้ว แทน​การ​ลบ​ทิ้ง​จริง เพราะ transaction ที่​ยัง​อ่าน snapshot เก่า​อยู่​ต้อง​ยัง​เห็น​ค่า​เดิม​ได้ และ​มัน​คือ​หลักฐาน​ชิ้น​เดียว​ที่​บอกว่า​คีย์​นี้​ถูกลบ​ไป​แล้ว

ความ​ไม่​สมมาตร​ตรง​นี้​คือ​เหตุผล​ทั้งหมด​ที่​กฎ​ข้อ​ที่​สอง​มี​อยู่

src/txn.rs · กฎ​ข้อ​ที่​สอง ตรวจ​ว่า​ใคร​ปิด version นี้​ไป​แล้ว
match v.end {
Some(end) => end != me && !snapshot.contains(&end),
None => true,
}

การ​แก้​ค่า​แสดง​ตัว​ได้​สอง​ทาง ผ่าน​ช่อง end ของ​ตัว​เก่า​และ​ผ่าน version ใหม่​ท้าย chain ส่วน​การ​ลบ​แสดง​ตัว​ได้​ทาง​เดียว คือ​ผ่าน​ช่อง end เท่านั้น เพราะ​มัน​ไม่​ได้​ทิ้ง​อะไร​ไว้​ท้าย chain เลย

ตัด​กฎ​ข้อ​นี้​ทิ้ง​แล้ว​รัน​ทั้ง​แปดบท​เทียบ​กับ​ผล​ที่​ตรึง​ไว้ มี​บรรทัด​เดียว​ใน​ทั้ง​คอร์ส​ที่​เปลี่ยน คือ​จำนวน​คีย์​ที่​มอง​เห็น​หลัง​ลบ​ใน​บท​นี้ จาก 0 กลาย​เป็น 1

🔁 อ่าน​คู่​กับ​กฎ​ข้อ​แรก​จาก​บท​ที่ 1

บท​ที่ 1 เขียน​ไว้​ว่า​กฎ​ข้อ​ที่​สอง​ดูเหมือน​ของ​แถม แล้ว​ชี้​มา​ที่​บท​นี้ ตอน​นี้​คำ​ตอบ​ครบ​แล้ว

กฎ​ข้อ​แรก​กัน version ที่​คน​สร้าง​ยัง​ไม่ commit ส่วน​กฎ​ข้อ​ที่​สอง​ทำงาน​อยู่​กรณี​เดียว​คือ​การ​ลบ และ​การ​ทดลอง​ข้าง​บน​วัด​ออก​มา​เป็น​บรรทัด​เดียว​พอดี

นี่​คือ​ทรง​ของ​กฎ​ที่​ดูเหมือน​ตาย​แล้ว​แต่​ไม่​ตาย ถ้า​ไม่มี scenario ที่​ลบ​จริง จะ​ไม่มี​เทสต์ไหน​แดง เวลา​ใคร​เผลอ​ลบ​กฎ​นี้​ออก

หัวข้อ​นี้​แสดง​ผล​รัน​หลัง​การ​ลบ ที่​มอง​ไม่​เห็น​คีย์​เลย​สัก​คีย์ ทั้ง​ที่​ใน​ที่​เก็บ​ยัง​มี version สอง​ตัว

cargo run --quiet -- 04
หลังลบ · จำนวนคีย์ที่มองเห็น 0
จำนวน version ที่ยังอยู่ในที่เก็บ 2
การลบไม่ได้ลบอะไรทิ้ง มันแค่ปิด version เดิมไว้ ของยังกินที่อยู่

คีย์​หาย​ไป​จาก​ผล​ของ read_all เพราะ read คืน None ไม่ใช่​เพราะ​มี​ใคร​ไป​เอา​อะไร​ออก​จาก​ที่​เก็บ

ตัวเลข​สอง​บรรทัด​แรก​จึง​ไม่​ขัด​กัน มัน​วัด​คนละ​อย่าง บรรทัด​แรกวัด​สิ่ง​ที่​ผู้​อ่าน​คน​นี้​มอง​เห็น บรรทัด​ที่​สอง​วัด​สิ่ง​ที่​กิน​ที่​อยู่​จริง

การ​ลบ​ใน​เอนจิน​แบบ​นี้​จึง​ไม่​ได้​คืน​ที่​ให้​ใคร​สัก​หน่วย​เดียว ตรง​กัน​ข้าม มัน​เพิ่ม​งาน​ให้​ตัว​เก็บกวาด

นี่​คือ​ราคา​ที่​ยัง​ไม่​ได้​จ่าย​ตอน​ลบ แต่​ต้อง​จ่าย​ทีหลัง ใน PostgreSQL คน​ที่มา​เก็บ​ของ​พวก​นี้​คือ VACUUM และ​ของ​ที่​มัน​เก็บ​คือ version ที่​ไม่มี transaction ไหน​ต้อง​ใช้​อีก​แล้ว

⚠️ เก็บกวาด​แล้ว ไม่​ได้​แปล​ว่าที่​ว่าง​คืน​กลับ​ไป​ให้​ระบบ​ปฏิบัติการ

เอกสาร​ของ PostgreSQL เขียนไว้ตรงๆ ว่า VACUUM แบบ​มาตรฐาน​ทำ​เครื่องหมาย​ที่​ว่าง​ไว้​ให้​แถว​ใหม่ มา​ใช้​ซ้ำ​ได้ แต่​ไม่​คืน​ที่​ว่าง​นั้น​กลับ​ไป​ให้​ระบบ​ปฏิบัติการ

ยกเว้น​กรณี​พิเศษ​ที่​หน้า​ท้าย​ตาราง​ว่าง​ทั้ง​หน้า​และ​ขอ lock ระดับ​ตาราง​ได้​ง่าย งาน​คืน​ที่​จริงจัง เป็น​ของ VACUUM FULL ซึ่ง​เป็น​คนละ​คำ​สั่ง​และ​มี​ราคา​คนละ​แบบ

ข้อ​นี้​ตรง​กับ​สิ่ง​ที่​เอนจินของ​เรา​แสดง​พอดี เลข 2 ใน​ผล​รัน​ข้าง​บน​ไม่​ลด​ลง​เอง เพียง​เพราะ​ไม่มี​ใคร มอง​เห็น​ของ​สอง​ตัว​นั้น​แล้ว

หัวข้อ​นี้​อธิบาย​เงื่อนไข v.begin != me และ​เหตุผล​ที่​ฐาน​ข้อมูล​จริง​เลือก​ทำ​แบบ​เดียวกัน

กฎ​ข้อ​แรก​มี​เงื่อนไขสั้นๆ อยู่​ท่อน​หนึ่ง​ที่​เปลี่ยน​พฤติกรรม​ของ​ทั้ง​เอนจิน

src/txn.rs · กฎ​ข้อ​แรก กับ​ข้อ​ยกเว้น​ให้​เจ้าตัว
if v.begin != me && !snapshot.contains(&v.begin) {
return false;
}

ถ้า​ไม่มี v.begin != me ตรง​นั้น transaction จะ​มอง​ไม่​เห็น​สิ่ง​ที่​ตัวเอง​เพิ่ง​เขียน เพราะ​หมายเลข ของ​ตัวเอง​ไม่มี​วัน​อยู่​ใน snapshot ของ​ตัวเอง

snapshot ถูก​จับ​ตอน​เปิด และ​ตอน​นั้น​ตัวเอง​ยัง​ไม่ commit จึง​ยัง​ไม่​อยู่​ใน​ทะเบียน

ผล​ที่​ได้​ถ้า​ไม่มี​ข้อ​ยกเว้น​นี้ ไม่ใช่​การ​อ่าน​ได้​ค่า​เก่า แต่​คือ​อ่าน​ไม่​เจอ​อะไร​เลย read คืน None สำหรับ​คีย์​ที่​ตัวเอง​เพิ่ง​เขียน

เหตุผล​คือ​กฎ​ทั้ง​สอง​ข้อ​ปฏิเสธ​พร้อม​กัน version ใหม่​ตก​กฎ​ข้อ​แรก​เพราะ​หมายเลข​ของ​เรา​ไม่​อยู่​ใน snapshot ส่วน version เดิม​ตก​กฎ​ข้อ​ที่​สอง เพราะ​คน​ที่​ปิด​มัน​คือ​เรา​เอง

แปล​ว่า BEGIN แล้ว UPDATE แล้ว SELECT จะ​คืน​ผลลัพธ์​ว่าง ไม่มี​ใคร​เขียน​โปรแกรม บน​พฤติกรรม​แบบ​นั้น​ได้

ฐาน​ข้อมูล​จริง​เลือก​ทาง​เดียวกัน งาน​ที่​ยัง​ไม่ commit มอง​เห็น​ได้​จาก​ข้าง​ใน​เสมอ และ​มอง​ไม่​เห็น​เลย จาก​ข้าง​นอก ข้อ​ยกเว้น​บรรทัด​เดียว​นี้​คือ​เส้น​แบ่ง​นั้น

ถึง​ตรง​นี้​กติกา​ชุด​หนึ่ง​ครบ​แล้ว ทุก​คน​อ่าน​จาก snapshot ของ​ตัวเอง ไม่มี​ใคร​รอ​ใคร และ​ไม่มี​ใคร เห็น​ของ​ที่​ยัง​ไม่ commit ของ​คน​อื่น

บท​ที่ 5 จะ​เอา​กติกา​ชุด​นี้​ไป​เจอ​กับ scenario ที่​ทุก​คน​ตัดสิน​ใจ​ถูก​ตาม​ภาพ​ที่​ตัวเอง​เห็น แล้ว​ผล​รวม​พัง​ลง​โดย​ไม่มี​ใคร​ถูก abort สัก​ครั้ง


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • PostgreSQL 18 Documentation — 13.1. Introduction (Concurrency Control) (ตรวจ​แล้ว 2026-08-13) — หน้าที่​ระบุ​ว่า​ทุก​คำ​สั่ง​เห็น snapshot ของ​ข้อมูล และ​ระบุ​ข้อ​ได้​เปรียบ ของ MVCC ไว้​ว่า “reading never blocks writing and writing never blocks reading”
  • PostgreSQL 18 Documentation — 24.1. Routine Vacuuming (ตรวจ​แล้ว 2026-08-13) — อธิบาย​ว่า UPDATE และ DELETE ไม่​ลบ version เก่า​ทิ้ง​ทันที เพราะ transaction อื่น​อาจ​ยัง​ต้อง​เห็น​มัน และ​ระบุ​ว่า VACUUM มาตรฐาน​ทำ​ที่​ว่าง​ให้​ใช้​ซ้ำ​ได้ แต่​ไม่​คืน​ที่​ว่าง​กลับ​ไป​ให้​ระบบ​ปฏิบัติการ

เช็กความเข้าใจ — บทที่ 4

ข้อ 1 / 3

เอนจินนี้ตรวจช่อง end ในกฎการมองเห็น แต่การแก้ค่าธรรมดาให้ผลเหมือนเดิมทุกบรรทัดถึงแม้จะตัดการตรวจนั้นทิ้ง ข้อใดอธิบายเหตุผลได้ถูกต้อง