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

capstone — เปิด HTTP ด้วย JDK เปล่า แล้ว​เลือก​โหมด​ล้ม​เหลว​ที่​บรรทัด​เดียว

เจ็ด​บท​ที่​ผ่าน​มา orderctl ทำงาน​ที​ละ​ใบ อ่า​นอ​อเด​อร์ ตรวจ คิด​เงิน แล้ว​จบ

บท​นี้​เปลี่ยน​สอง​อย่าง​พร้อม​กัน มัน​เปิด port ให้​คน​อื่น​ยิง​เข้า​มา และ​มัน​คิด​เงิน​หลาย​ใบ​พร้อม​กัน​ได้

📦 สิ่ง​ที่​บท​นี้​เพิ่ม​ให้ orderctl
  • Quotes.quote — ตรรกะ​ของ POST /quote ที่​รับ string คืน string และ​ไม่รู้​จัก HTTP เลย
  • QuoteHandler — ตัว​รับคำ​ขอ​ที่​ปิด​ช่อง​ซึ่ง com.sun.net.httpserver ไม่​ปิด​ให้
  • Serve — เปิด port แล้ว​ผูก executor ที่​แจก virtual threadvirtual threadthread ที่​ตัว​จัด​คิว​ของ JVM ดูแล​เอง สร้าง​พร้อม​กัน​จำนวน​มาก​ได้ · Kotlin เรียก​ใช้ได้​โดย​ไม่​ต้อง​เพิ่ม dependency สัก​บรรทัด ต่าง​จาก coroutine ที่​อยู่​นอก​ภาษา​และ​ต้อง​ประกาศ​เอง ให้​ทุก​คำขอ
  • Batch.chargeAll — คิด​เงิน​หลาย​ใบ​พร้อม​กัน​โดย​ไม่​เพิ่ม dependency สัก​บรรทัด

ครึ่ง​แรก​ของ​บท​ไม่​ต้อง​เพิ่ม​อะไร​เลย ครึ่ง​หลัง​เพิ่ม​หนึ่ง​ใบ คือ kotlinx-coroutines-core ซึ่ง​เป็น​เครื่อง​มือ​เรือ​ธง​ของ Kotlin ที่​ไม่​ได้​อยู่​ใน​ภาษา

interface ที่​มี method เดียว​ของ Java รับ lambda ของ Kotlin ได้​ตรงๆ ด้วย SAM conversion

com.sun.net.httpserver อยู่​ใน module jdk.httpserver ของ JDK คอร์ส​นี้​จึง​เปิด endpoint ได้​โดย​ไม่​แตะ build.gradle.kts เลย เท่ากับ​คอร์ส Java พอดี

ที่​ต่าง​คือ​รูป​ของ handler · HttpHandler มี method เดียว​คือ handle Kotlin จึง​แปลง lambda เป็น​มัน​ได้​เอง

src/main/kotlin/orderctl/Health.kt
package orderctl
import com.sun.net.httpserver.HttpServer
/** เส้นทางที่ไม่มีตรรกะเลย · handler ทั้งตัวเป็น lambda ก้อนเดียว */
fun addHealth(server: HttpServer) {
server.createContext("/health") { exchange ->
exchange.sendResponseHeaders(204, -1)
exchange.close()
}
}

คอร์ส Java เขียน handler ของ​ตัว​เอง​เป็น class เต็ม​ที่ implements HttpHandler พร้อม @Override และ constructor ของ​ตัว​เอง · ฝั่ง​นี้​ไม่มี​ทั้ง​สาม​อย่าง

`createContext` จับ​เส้น​ทาง​แบบ prefix และ​ไม่​ดู HTTP method เลย

ลง​ทะเบียน /quote ไว้ แล้ว​ยิง POST /quote/A-1 เข้า​มา คำขอ​นั้น​เข้า handler ตัว​เดิม

$ curl -sS -o /dev/null -w '%{http_code}\n' -XPOST -d '{"sku":"ชา","qty":1,"unitSatang":3500}' 127.0.0.1:8080/quote/A-1
200

ServeMux ของ​คอร์ส Go รับ pattern แบบ mux.HandleFunc("POST /orders", h) และ​ตอบ 405 ให้​เอง​เมื่อ method ไม่​ตรง · ที่​นี่​ไม่มี​ทั้ง​สอง​อย่าง บรรทัด​ที่​ตรวจ method จึง​ต้อง​เป็น​บรรทัด​ที่​เรา​เขียน​เอง

ตัว​คิด​ราคา​รับ string คืน string จึง​เทสต์​ได้​โดย​ไม่​ต้อง​เปิด port

Quotes.quote ใช้ kotlinx.serialization ตัว​เดียว​กับ​บท​ที่ 3 และ​ได้​ความ​เข้ม​งวด ค่า​ปริยาย​ของ​มัน​มา​ด้วย

src/main/kotlin/orderctl/Quotes.kt
package orderctl
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class QuoteAsk(val sku: String, val qty: Int, val unitSatang: Long)
@Serializable
data class QuoteReply(val sku: String, val totalSatang: Long)
/** ตรรกะของ POST /quote · รับ string คืน string และไม่รู้จัก HTTP เลย */
object Quotes {
fun quote(body: String): String {
val ask = Json.decodeFromString<QuoteAsk>(body)
require(ask.qty > 0) { "qty ต้องมากกว่าศูนย์" }
return Json.encodeToString(QuoteReply(ask.sku, ask.qty * ask.unitSatang))
}
}

คีย์​ที่​หาย​ไป​กับ​ค่าที่​ผิดก​ติ​กา จึง​ถูก​ปฏิเสธ​คนละ​ชั้น​กัน ชั้น​แรก​เป็น​ของ library ชั้น​ที่​สอง​เป็น​ของ​เรา

$ curl -sS -XPOST -d '{"sku":"ชาไทยเย็น","qty":2}' 127.0.0.1:8080/quote
{"error":"Field 'unitSatang' is required for type with serial name 'orderctl.QuoteAsk', but it was missing at path: $"}
$ curl -sS -XPOST -d '{"sku":"ชาไทยเย็น","qty":0,"unitSatang":3500}' 127.0.0.1:8080/quote
{"error":"qty ต้องมากกว่าศูนย์"}

ความ​ยาว​ของ​คำ​ตอบ​นับ​เป็น​ไบต์ ชนิด​ของ​มัน​เป็น Long และ​ข้อ​ยกเว้น​ที่​หลุด​ออก​ไป​หาย​เงียบ

src/main/kotlin/orderctl/QuoteHandler.kt
package orderctl
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpHandler
import java.nio.charset.StandardCharsets
/** ตัวรับคำขอของ POST /quote · ตรรกะถูกส่งเข้ามาทาง constructor */
class QuoteHandler(private val logic: (String) -> String) : HttpHandler {
override fun handle(exchange: HttpExchange) {
exchange.use {
if (exchange.requestMethod != "POST") {
exchange.sendResponseHeaders(405, -1)
return@use
}
val body = exchange.requestBody.readBytes().toString(StandardCharsets.UTF_8)
val (status, reply) = try {
200 to logic(body)
} catch (e: RuntimeException) {
400 to """{"error":"${e.message}"}"""
}
val out = reply.toByteArray(StandardCharsets.UTF_8)
exchange.responseHeaders.set("Content-Type", "application/json; charset=utf-8")
exchange.sendResponseHeaders(status, out.size.toLong())
exchange.responseBody.use { it.write(out) }
}
}
}

-1 ที่​บรรทัด 405 คือ​ค่าที่​บอก​ว่า​ไม่มี​เนื้อ​ความ​ตาม​มา​เลย · ส่วน​คำ​ตอบ​ที่​มี​เนื้อ​ความ ต้อง​บอก​ความ​ยาว​เป็น​ตัวเลข​จริง ซึ่ง​เป็น​ที่มา​ของ​หัวข้อ​ย่อย​ถัด​ไป

sendResponseHeaders ประกาศ argument ตัว​ที่​สอง​เป็น Long ส่วน ByteArray.size เป็น Int · Kotlin ไม่​แปลง​ชนิด​ตัวเลข​ให้​เงียบๆ เหมือน Java

// ❌ แปลไม่ผ่าน
import com.sun.net.httpserver.HttpExchange
fun bad(exchange: HttpExchange, out: ByteArray) {
exchange.sendResponseHeaders(200, out.size)
}
Bad.kt:5:39: error: argument type mismatch: actual type is 'Int', but 'Long' was expected.
exchange.sendResponseHeaders(200, out.size)
^^^^^^^^

การ​บังคับ​ให้​พิมพ์ .toLong() ดู​จุกจิก แต่​มัน​ตก​อยู่​ตรง​บรรทัด​ที่​ผิด​พลาด​ง่าย​ที่สุด​พอดี เพราะ​สิ่ง​ที่​ต้อง​ส่ง​คือจำนวน​ไบต์ ไม่ใช่​จำนวน​ตัว​อักษร

$ curl -sS -XPOST -d '{"sku":"ชาไทยเย็น","qty":2,"unitSatang":3500}' 127.0.0.1:8080/quote
{"sku":"ชาไทยเย็น","totalSatang":7000}
$ curl -sS -o /dev/null -D - -XPOST -d '{"sku":"ชาไทยเย็น","qty":2,"unitSatang":3500}' 127.0.0.1:8080/quote | grep -i content
Content-type: application/json; charset=utf-8
Content-length: 56

คำ​ตอบ​ก้อน​นั้น​มี 38 ตัว​อักษร แต่​กิน 56 ไบต์ เพราะ ชาไทยเย็น เก้า​ตัว​อักษร​กิน​ไป 27 ไบต์

เปลี่ยน​บรรทัด​นั้น​เป็น reply.length.toLong() แล้ว​วัด​ใหม่ · ปลาย​สาย​ไม่​ได้​เนื้อ​ความ​ที่​ขาด​ครึ่ง มัน​ไม่​ได้​อะไร​เลย

client ได้: java.io.IOException: HTTP/1.1 header parser received no bytes

ข้อความ​นั้น​ไม่มี​คำ​ใบ้​สัก​คำ​ว่า​ต้น​เหตุ​คือ​หน่วย​ของ​ความ​ยาว · เซิร์ฟเวอร์​เขียน​เกิน​จำนวน ที่​ประกาศ​ไว้ แล้ว​ปิด​สาย​ทิ้ง​ก่อน​ที่ header จะ​ออก​ไป​ถึง​ใคร

ข้อ​ยกเว้น​ที่​หลุด​จาก handler ไม่​โผล่​ที่ไหน​เลย

try ใน handle ไม่ใช่​ของ​เผื่อ​ไว้ · ลอง​ปล่อย​ให้​ข้อ​ยกเว้น​หลุด​ออก​จาก handler แล้ว​ดู​สิ่ง​ที่​ปลาย​สาย​ได้​รับ

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์
server.createContext("/boom") { _ -> throw IllegalStateException("ระเบิดกลาง handler") }
client ได้: java.io.IOException: HTTP/1.1 header parser received no bytes

ไม่มี​สถานะ ไม่มี​เนื้อ​ความ และหน้า​จอ​ฝั่ง​เซิร์ฟเวอร์​ไม่​พิมพ์​อะไร​เลย · สังเกต​ว่า​มัน​เป็น​ข้อความ​เดียว​กับ​ตอน​ที่​ประกาศ​ความ​ยาว​ผิด​เป๊ะ ทั้ง​ที่​ต้น​เหตุ​คนละ​เรื่อง​กัน

นี่​คือ​ข้อ​เดียว​กับ​ที่​คอร์ส Java เจอ เพราะ​เป็น​เซิร์ฟเวอร์​ตัว​เดียวกัน​ของ JDK · catch ที่​เขียน​เอง​จึง​เป็น​ชั้น​เดียว​ที่​เปลี่ยน​สาย​ที่​ขาด​ให้​กลาย​เป็น 400 ที่​อ่าน​ออก

ไม่​เรียก setExecutor แล้ว​คำขอ​ทุก​ใบ​เดิน​บน thread เดียว

src/main/kotlin/orderctl/Serve.kt
package orderctl
import com.sun.net.httpserver.HttpHandler
import com.sun.net.httpserver.HttpServer
import java.net.InetSocketAddress
import java.util.concurrent.Executors
/** เปิด port แล้วผูก handler เข้ากับเส้นทาง /quote */
object Serve {
fun start(port: Int, handler: HttpHandler): HttpServer {
val server = HttpServer.create(InetSocketAddress("localhost", port), 0)
server.executor = Executors.newVirtualThreadPerTaskExecutor()
server.createContext("/quote", handler)
server.start()
println("orderctl ฟังที่ http://localhost:${server.address.port}")
return server
}
}

start รับ handler เข้า​มา​แทนที่​จะ​สร้าง​เอง ตัว​ที่​ประกอบ​สอง​ชิ้น​เข้า​ด้วย​กัน​จึง​เป็น main

// ชิ้นส่วน ไม่ใช่โปรแกรมสมบูรณ์ · เติมท้าย Serve.kt
fun main() {
Serve.start(8080, QuoteHandler(Quotes::quote))
}

เขียน​แค่​นั้น​แล้ว gradle run ยัง​เรียก​มัน​ไม่​ได้ เพราะ build.gradle.kts ตั้ง​ปลาย​ทาง​ไว้​ที่ orderctl.MainKt ซึ่ง​เป็น CLI ของ​บท​ที่ 7 · ปลาย​ทาง​ของ​บท​นี้​จึง​ต้อง​ย้าย

build.gradle.kts — บรรทัด​ที่​บท​นี้​แก้
application { mainClass = "orderctl.ServeKt" }

ชื่อ ServeKt มา​จาก​ชื่อ file ตาม​กฎ​เดียว​กับ HelloKt ของ​บท​ที่ 1

server.executor = ... คือ​รูป property ของ Kotlin ที่​แปล​เป็น setExecutor ของ Java ตรงๆ · มัน​เป็น​บรรทัด​ที่​เปลี่ยน​พฤติกรรม​ของ​ทั้ง​เซิร์ฟเวอร์

ยิง​คำขอ​แปด​ใบ​พร้อม​กัน​เข้า handler ที่​บล็อก​อยู่ แล้วนับ thread ที่​ลงมือ​ทำงาน​จริง

สิ่ง​ที่​วัดไม่​เรียก setExecutornewVirtualThreadPerTaskExecutor
thread ที่​ต่าง​กัน18
จำนวน​ที่​ทำงาน​พร้อม​กัน​สูงสุด18

ตัวเลข​คู่​นี้​ไม่ใช่​การ​จับ​เวลา มัน​คือ​การ​นับ threadId() ที่​ไม่​ซ้ำ​กัน · ผลอ​อก​มา​เท่า​เดิม​ทุกร​อบ​ที่​รัน ต่าง​จาก​ตัวเลข​เวลา​ที่​ขยับ​ตาม​เครื่อง

ลำดับ​สี่​บรรทัด​นั้น​บังคับ

create แล้ว setExecutor แล้ว createContext แล้ว start · สลับ​สอง​ตัวกลาง​ได้ แต่​ย้าย​การ​ตั้ง executor ไป​หลัง start() แล้ว​มัน​โยน​ทันที

Exception in thread "main" java.lang.IllegalStateException: server already started
at jdk.httpserver/sun.net.httpserver.ServerImpl.setExecutor(ServerImpl.java:197)
at jdk.httpserver/sun.net.httpserver.HttpServerImpl.setExecutor(HttpServerImpl.java:57)
at AfterStartKt.main(AfterStart.kt:7)
at AfterStartKt.main(AfterStart.kt)

ข้อดี​คือ​มัน​ดัง ไม่ใช่​เงียบ · เซิร์ฟเวอร์​ที่​แจก thread ผิด​แบบ​จะ​ไม่​หลุด​ขึ้น production ไป​โดยที่​ไม่มี​ใคร​รู้

คิด​เงิน​หลาย​ใบ​พร้อม​กัน โดย​ยัง​ไม่​เพิ่ม dependency

หัวข้อ​ที่​มีชื่อ​ว่า “คิด​เงิน​หลาย​ใบ​พร้อม​กัน โดย​ยัง​ไม่​เพิ่ม dependency”

chargeAll แจก virtual threadvirtual threadthread ที่​ตัว​จัด​คิว​ของ JVM ดูแล​เอง สร้าง​พร้อม​กัน​จำนวน​มาก​ได้ · Kotlin เรียก​ใช้ได้​โดย​ไม่​ต้อง​เพิ่ม dependency สัก​บรรทัด ต่าง​จาก coroutine ที่​อยู่​นอก​ภาษา​และ​ต้อง​ประกาศ​เอง ใบ​ละ​ตัว แล้ว​ให้ use รอ​ทุก​ใบ​จบ​ก่อน​คืน​ค่า

ExecutorService เป็น AutoCloseable และ close() ของ​มัน​บล็อก​จน​งาน​ที่​ส่ง​ไป​แล้ว​จบ​ครบ · ฝั่ง Kotlin จึง​เขียน​มัน​เป็น use { } ตัว​เดียว​กับ​ที่​บท​ที่ 6 ใช้​ปิด resource

src/main/kotlin/orderctl/Batch.kt
package orderctl
import java.util.concurrent.Executors
import java.util.concurrent.Future
/** คิดเงินหลายใบพร้อมกันด้วย virtual thread · ไม่มี dependency เพิ่ม */
object Batch {
fun chargeAll(ids: List<String>, charge: (String) -> Long): Long {
val pending: List<Future<Long>>
Executors.newVirtualThreadPerTaskExecutor().use { pool ->
pending = ids.map { id -> pool.submit<Long> { charge(id) } }
}
return pending.sumOf { it.get() }
}
}

it.get() ใน​บรรทัด​สุดท้าย​ไม่ใช่​ของ​ประดับ มัน​คือ​จุด​เดียว​ที่​ความ​ผิด​พลาด​ของ​แต่ละ​ใบ​โผล่​ออก​มา · และ​ตัว​ที่​โผล่​ออก​มา​เป็น​ตัว​ห่อ ไม่ใช่​ตัว​จริง

เรียก​มัน​สอง​รอบ​ด้วย​รายการ​เดียวกัน​สาม​ใบ · รอบ​แรก​ทุก​ใบ​คิด​ได้​ใบ​ละ 7000 สตางค์ รอบ​ที่​สอง​ให้​ใบ B โยน IllegalStateException ออก​มา

ครบสามใบ = 21000
โยนออกมาเป็น java.util.concurrent.ExecutionException
getCause java.lang.IllegalStateException: ใบ B ถูกปฏิเสธ

งาน​ที่ submit แล้ว​ไม่มี​ใคร​เรียก get() คือ​ความ​ผิด​พลาด​ที่​หาย​ไป​ตลอด​กาล · ข้อ​นี้​เหมือน​คอร์ส Java ทุก​ตัว​อักษร เพราะ​เป็น ExecutorService ตัว​เดียวกัน · หัวข้อ​ถัด​ไป​จะ​แสดง​ว่า coroutine ให้​ทาง​เลือก​อีก​ทาง​ที่​ไม่​ต้อง​พึ่ง​วินัย​ของ​คน​เขียน

suspend เป็น​คำ​ใน​ภาษา แต่​ของ​ที่​ทำให้​มัน​มี​ประโยชน์​อยู่​ใน​กล่อง​คนละ​ใบ

ชั้น​แรก​คือ dependency · import kotlinx.coroutines.runBlocking เปล่าๆ ไม่​ผ่าน

$ kotlinc -Werror -Wextra -jvm-target 25 NoDep.kt -d outnd
NoDep.kt:1:8: error: unresolved reference 'kotlinx'.
import kotlinx.coroutines.runBlocking
^^^^^^^
NoDep.kt:2:14: error: unresolved reference 'runBlocking'.
fun main() = runBlocking { println("ok") }
^^^^^^^^^^^

บรรทัด​ที่​ปิด​ชั้น​นี้​มี​บรรทัด​เดียว และ​มัน​คือ​ใบ​เดียว​ที่​ครึ่ง​หลัง​ของ​บท​นี้​เพิ่ม​เข้า​มา

build.gradle.kts — บรรทัด​ที่​บท​นี้​เพิ่ม
dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") }

เอกสาร​ทางการ​เขียน​เส้น​แบ่ง​นี้​ไว้​เอง

“While the suspend keyword is part of the core Kotlin language, most coroutine features are available through the kotlinx.coroutines library.”

ชั้น​ที่​สอง​คือ​กฎ​ของ​ภาษา · suspend functionsuspend functionfunction ที่​หยุด​กลาง​ทาง​ได้​โดย​ไม่​ยึด thread ไว้ · เรียก​จาก function ธรรมดา​ไม่​ได้ และ​เขียน​ทับ method ของ Java ไม่​ได้ ประตู​เดียว​ที่​เข้าไป​ได้​คือ `runBlocking` เรียก​จาก function ธรรมดา​ไม่​ได้

// ❌ แปลไม่ผ่าน
package orderctl
import kotlinx.coroutines.delay
suspend fun settle(id: String): String {
delay(10)
return id
}
fun chargeOne(id: String): String = settle(id)
Call.kt:11:37: error: suspend function 'suspend fun settle(id: String): String' can only be called from a coroutine or another suspend function.
fun chargeOne(id: String): String = settle(id)
^^^^^^

และ​ประตู​นั้น​ปิด​สนิท​กับ interface ของ Java ด้วย · HttpHandler.handle ไม่ใช่ suspend จึง​เขียน​ทับ​ด้วย suspend ไม่​ได้

// ❌ แปลไม่ผ่าน
package orderctl
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpHandler
import kotlinx.coroutines.delay
class SlowHandler : HttpHandler {
override suspend fun handle(exchange: HttpExchange) {
delay(10)
exchange.sendResponseHeaders(204, -1)
}
}
Susp.kt:9:26: error: suspend function 'handle' cannot override non-suspend function 'fun handle(p0: HttpExchange!): Unit' defined in 'com.sun.net.httpserver.HttpHandler'.
override suspend fun handle(exchange: HttpExchange) {
^^^^^^

นี่​คือ​เหตุผล​ที่​ครึ่ง​แรก​ของ​บท​ไม่มี coroutine เลย ชั้น HTTP ของ JDK เป็น API แบบ​บล็อก และ​ทาง​เข้า​เดียว​จาก handler ไปหา coroutine คือ runBlocking ซึ่ง​ยึด thread ที่​เรียก​มัน​ไว้

coroutineScope ส่ง​ความ​ผิด​พลาด​ของ​ลูก​ที่​ไม่มี​ใคร​ถาม​ถึง​ผู้​เรียก ส่วน supervisorScope ไม่​ส่ง

structured concurrencystructured concurrencyขอบเขต​ที่​งาน​ลูก​ทุก​ตัว​ต้อง​จบ​ก่อน​ขอบเขต​จะ​จบ · `coroutineScope` ส่ง​ความ​ผิด​พลาด​ของ​ลูก​ที่​ไม่มี​ใคร​ถาม​ถึง​ผู้​เรียก ส่วน `supervisorScope` เก็บ​มัน​ไว้​เงียบๆ แบบ​เดียว​กับ `submit` ของ Java คือ​กติกา​ที่​ว่า​งาน​ลูก​ทุก​ตัว​ต้อง​จบ​ก่อน​ขอบเขต​จะ​จบ · เอกสาร​ทางการ​อธิบาย​กติกา​นั้น​ไว้​สอง​ประโยค

“A parent coroutine waits for its children to complete before it finishes. If the parent coroutine fails or gets canceled, all its child coroutines are recursively canceled too.”

ประโยค​ที่​สอง​คือ​ของ​ที่​วัด​ได้ · ใบ B ถูก​ปฏิเสธ​เสมอ และไม่มี​ใคร​เรียก await() ของ​มัน​เลย

src/main/kotlin/orderctl/Scopes.kt
package orderctl
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.supervisorScope
/** ใบ B ถูกปฏิเสธเสมอ · ใบอื่นคิดเงินได้ตามปกติ */
private suspend fun settle(id: String): Long {
if (id == "B") throw IllegalStateException("ใบ $id ถูกปฏิเสธ")
return 7000
}
fun main() = runBlocking {
val strict = runCatching {
coroutineScope {
val a = async { settle("A") }
async { settle("B") }
a.await()
}
}
println("coroutineScope -> " + strict.exceptionOrNull()?.message)
val lenient = runCatching {
supervisorScope {
val a = async { settle("A") }
async { settle("B") }
a.await()
}
}
println("supervisorScope -> " + lenient.getOrNull())
}
coroutineScope -> ใบ B ถูกปฏิเสธ
supervisorScope -> 7000

บรรทัด​บน​คือ​โหมด​ของ Go · ลูก​ใบ​เดียว​ล้ม​แล้ว​ทั้ง​กลุ่ม​ล้ม ผล​ของ​ใบ A หาย​ไป​ด้วย บรรทัด​ล่าง​คือ​โหมด​ของ Java · ผล​ของ​ใบ A รอด​มา และ​ความ​ผิด​พลาด​ของ​ใบ B ไม่​ไป​ถึง​ใคร

สอง​โหมด​นั้น​ต่าง​กัน​แค่​ชื่อ function ที่​พิมพ์ ไม่ใช่​โครงสร้าง​คนละ​แบบ · นี่​คือ​ของ​ที่​คอร์ส​นี้​มี​แล้ว​อีก​สอง​คอร์ส​ไม่มี

ฝั่ง Kotlin ประกาศ Stable ไป​แล้ว ส่วน​ฝั่ง Java ยัง preview

หน้า​สถานะ​ของ JetBrains มี​ตาราง​ชื่อ Kotlin libraries · แถว​ของ coroutine ทั้ง​แถว​เป็น​แบบ​นี้

ComponentStatusStatus since version
kotlinx-coroutinesStable1.3.0

หน้า​เดียวกัน​นิยาม​คำ​ว่า Stable ไว้​ว่า “use it even in most conservative scenarios” · ข้อ​นี้​เป็น​ข้อ​อ้าง​จาก​เอกสาร ไม่ใช่​ผล​จาก​เครื่อง หน่วย​ของ​มัน​จึง​เป็น​คำ​พูด​ของ​ต้นทาง

ฝั่ง Java ของ​เดียวกัน​ยัง​ไม่​ถึง​เส้น​นั้น · JDK 25 ที่​คอร์ส​นี้​ตรึง​ไว้​ได้​ของ​รอบ​ที่​ห้า

“JEP 505: Structured Concurrency (Fifth Preview)” · Status Closed/Delivered · Release 25

และ​รอบ​ล่าสุด​ที่​ประกาศ​แล้ว​คือ​รอบ​ที่​เจ็ด ซึ่ง​ไป​อยู่​บน JDK 27

“JEP 533: Structured Concurrency (Seventh Preview)” · Release 27

ทุกร​อบ​ต้อง​เปิด --enable-preview ทั้ง​ตอน​แปล​และ​ตอน​รัน และ class file ที่​ได้ รัน​ข้าม​รุ่น​ไม่​ได้ · คอร์ส​นี้​จึง​ไม่​ใช้ StructuredTaskScope เลย

Dispatchers.IO ตัน​ที่ 64 ส่วน virtual thread ขึ้น​ครบ​ทั้ง 500

ทั้ง​สอง​เส้น​ทำงาน​เดียวกัน​คือ​งาน​ที่​บล็อก​อยู่ 500 ชิ้น · เรา​ไม่​จับ​เวลา เรา​นับ threadId()

src/main/kotlin/orderctl/Lanes.kt
package orderctl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
/** นับ thread ที่ลงมือทำงานจริง · ไม่วัดเวลา วัดจำนวน */
private fun work(seen: MutableSet<Long>) {
seen.add(Thread.currentThread().threadId())
Thread.sleep(300)
}
fun main() {
val io = ConcurrentHashMap.newKeySet<Long>()
runBlocking {
repeat(500) { launch(Dispatchers.IO) { work(io) } }
}
println("Dispatchers.IO = " + io.size)
val vt = ConcurrentHashMap.newKeySet<Long>()
Executors.newVirtualThreadPerTaskExecutor().use { pool ->
repeat(500) { pool.submit { work(vt) } }
}
println("virtual thread = " + vt.size)
}
Dispatchers.IO = 64
virtual thread = 500
flowchart LR
  Q["งานที่บล็อกอยู่ 500 ชิ้น"] --> V["Executors.newVirtualThreadPerTaskExecutor<br/>อยู่ใน JDK ไม่ต้องเพิ่มอะไร"]
  Q --> D["launch(Dispatchers.IO)<br/>ต้องมี kotlinx-coroutines-core"]
  V --> VN["นับ threadId ที่ไม่ซ้ำ = 500"]
  D --> DN["นับ threadId ที่ไม่ซ้ำ = 64"]
  DN --> P["ตั้ง kotlinx.coroutines.io.parallelism เป็น 200<br/>แล้วนับใหม่ได้ 200"]

คำ​บรรยาย​ภาพ: งาน​ชุด​เดียวกัน​เดิน​ได้​สอง​เส้น · เส้น​บน​อยู่​ใน​กล่อง​ของ JDK แล้ว เส้น​ล่าง​ต้อง​ประกาศ dependency เพิ่ม และ​มี​เพดาน​ที่​ปรับ​ได้

เลข 64 ไม่ใช่​ค่า​คงที่​ของ​จักรวาล มัน​เป็น​ค่า​ปริยาย​ที่​เอกสาร​ของ Dispatchers.IO เขียน​ไว้

“It defaults to the limit of 64 threads or the number of cores (whichever is larger).”

เครื่อง​ที่​ใช้​วัด​มี 18 core เลข​ที่​ได้​จึง​เป็น 64 · และ​เมื่อ​สั่ง​รัน​ใหม่​โดย​ตั้ง kotlinx.coroutines.io.parallelism เป็น 200 ตัวเลข​ที่​นับ​ได้​ขยับ​เป็น 200 ตรง​ตาม​ที่​ประกาศ

เพดาน​นี้​ไม่ใช่​ข้อ​บกพร่อง และ virtual thread ก็​ไม่ใช่​คำ​ตอบ​ของ​ทุก​ข้อ

Dispatchers.IO แจก​งาน​ลง​บน pool ที่​ใช้​ร่วม​กัน และ​เอกสาร​ของ​มัน​เรียก​เลข 64 ว่า limit · 500 ตัว​ที่ virtual thread สร้าง​ได้​ไม่ใช่​ของ​ชนิด​เดียวกัน javadoc ของ Thread อธิบาย​ว่า มัน​เป็น thread ระดับ user-mode ที่ Java runtime จัด​คิว​เอง ไม่ใช่​ระบบ​ปฏิบัติ​การ

หน้า​เดียวกัน​เขียน​ขอบ​ของ​มัน​ไว้​สอง​ประโยค​ติด​กัน

“Virtual threads are suitable for executing tasks that spend most of the time blocked, often waiting for I/O operations to complete. Virtual threads are not intended for long running CPU intensive operations.”

orderctl เข้า​ประโยค​แรก​พอดี เพราะ​คำขอ​หนึ่ง​ใบ​หมด​ไป​กับ​การ​รอ​สาย ไม่ใช่​การ​คำนวณ

ข้อ​ที่​ต้อง​รู้​ก่อน​เอา​ไป​ใช้​กับ​งาน​จริง​คือ​เรื่อง​การ​ตรึง ซึ่ง​ไม่ใช่​เรื่อง​ของ​คอร์ส​นี้ · อ่าน​ต่อ​ได้ที่ virtual thread pinning หลัง JEP 491 ซึ่ง​เป็นการ​วัด​ข้าม​หลาย​รุ่น​พร้อม​ช่อง​ควบคุม

สี่​แถว​ที่​บท​นี้​แตะ​จริง และ​ทุก​แถว​มี​ราคา​ของ​ตัว​เอง

คอร์ส Go ปิด​บท​ที่ 8 ด้วย​ข้อ​ที่​ว่า​ตลอด​แปดบท​ไม่มี dependency ภายนอก​เพิ่ม​เข้า​มา​เลย ส่วน​คอร์ส Java เขียน​เอง​ว่า​ตัว​เอง​พูด​ได้​แค่​ครึ่ง​แรก · คอร์ส​นี้​อยู่​ตำแหน่ง​ที่​สาม

เรื่องGoJavaKotlin
routingmux.HandleFunc("POST /orders", h) อ่าน method ให้ และ​ตอบ 405 เองcreateContext จับ prefix ล้วน ต้อง​เขียน​บรรทัด​ตรวจ method เองเหมือน Java เป๊ะ เพราะ​เป็น​เซิร์ฟเวอร์​ตัว​เดียวกัน แต่ handler เป็น lambda ได้
งาน​พร้อม​กัน​ใน​กล่องgoroutine เป็น​คำ​ใน​ภาษาvirtual thread มา​กับ JDKvirtual thread มา​กับ JDK เหมือน​กัน แต่ coroutine ที่​เป็น​เครื่อง​มือ​เรือ​ธง​อยู่​นอก​ภาษา
การ​จับ data racego test -race ชี้​บรรทัด​ให้ และ​จับ​เฉพาะ​ที่​เกิด​จริง​ตอน​รันไม่มี​ใน toolchain มาตรฐานไม่มี​เหมือน​กัน · Lincheck 3.7 เป็น​เครื่อง​มือ​คนละ​ชนิด​ที่​ต้อง​ดึง​เพิ่ม
ลูก​ล้ม​หนึ่ง​ใบpanic ใน goroutine ที่​ไม่มี​ใคร​รับ ฆ่า​โปร​เซ​ส​ทั้ง​ตัวsubmit ที่​ไม่มี​ใคร get() กลืน​ความ​ผิด​พลาด​หาย​ไปเลือก​ได้ที่​บรรทัด​เดียว coroutineScope ทำ​แบบ Go ส่วน supervisorScope ทำ​แบบ Java

แถว​สุดท้าย​คือ​แถว​ที่​สรุป​ทั้ง​บท​ได้​ดี​ที่สุด · สอง​ภาษา​พี่​เลือก​โหมด​ความ​ล้ม​เหลว​ไว้​ให้​แล้ว และ​เปลี่ยน​ไม่​ได้ · Kotlin ย้าย​การ​เลือก​นั้น​มา​ไว้​ใน​มือ​คน​เขียน

แต่​ราคา​ของ​อิสระ​ข้อ​นั้น​อยู่​ใน​แถว​ที่​สอง · Kotlin เป็น​ภาษา​เดียว​ใน​สาม​ภาษา​ที่​เครื่อง​มือ เรือ​ธง​ของ​ตัว​เอง​ไม่​ได้​อยู่​ใน​ภาษา และ​การ​หยิบ​มัน​มา​ใช้​ทำให้​ข้อ​อ้าง “ไม่​เพิ่ม dependency” หมด​ไป

แปดบท​ต่อ​กัน​เป็น​เครื่อง​มือ​ตัว​เดียว และ​ตลอด​ทาง​ประกาศ dependency ของ​โดเมน​ไว้​สอง​ตัว

บทสิ่ง​ที่​เพิ่ม​เข้า​มา
1kotlinc 2.4.10 ที่​ติด​ตั้ง​แล้ว และ​โปรแกรม​แรก​ที่​รู้​ว่า HelloKt มา​จาก​ไหน
2ชนิด​ของ​โดเมน​ทั้ง​ชุด​ที่​สร้าง​สถานะ​ผิด​ไม่​ขึ้น
3build.gradle.kts ที่​เขียน​เอง​ทุก​บรรทัด และ JSON ที่​ตรวจ​คีย์​ให้​ทั้ง​สอง​ทาง
4ยอด​รวม การ​สรุป​ตาม​หมวด และ​เส้น​แบ่ง​ของ Sequence
5กฎ​ส่วนลด​ที่​ต่อ​เพิ่ม​ได้ และ​กับ​ดัก​ของ by
6การ​รายงาน​ทุก​ข้อ​ที่​ผิด​พร้อม​กัน และ​การ​ปิด resource ที่​ยัง​เห็น​ความ​ผิด​พลาด​ตอน​ปิด
7บรรทัด​คำ​สั่ง รหัส​ออก state บน disk และ​เทสต์​ชุด​แรก​ที่​ชื่อ​เป็น​ภาษา​ไทย
8POST /quote ที่​เปิด​ด้วย JDK เปล่า และ​การ​คิด​เงิน​หลาย​ใบ​พร้อม​กัน

หน้าต่าง​แรก เปิด port

$ gradle -q run
orderctl ฟังที่ http://localhost:8080

หน้าต่าง​ที่​สอง ยิง​เข้าไป

$ curl -sS -XPOST -d '{"sku":"ชาไทยเย็น","qty":2,"unitSatang":3500}' 127.0.0.1:8080/quote
{"sku":"ชาไทยเย็น","totalSatang":7000}

dependency ของ​โดเมน​ที่​คอร์ส​นี้​เพิ่ม​เข้า​มา​ตลอด​แปดบท​มี​สอง​ตัว คือ kotlinx.serialization ที่​บท​ที่ 3 เพราะ Kotlin ไม่มี JSON ใน​กล่อง และ kotlinx.coroutines ที่​บท​นี้

ฝั่ง HTTP ไม่​ต้อง​เพิ่ม​อะไร​เลย แต่​ฝั่ง​เทสต์​ต้อง​เพิ่ม testImplementation(kotlin("test")) ซึ่ง​อยู่​ใน build.gradle.kts มา​ตั้งแต่​บท​ที่ 3 แล้ว และ​มัน​ลาก JUnit 5 มา​จาก Maven Central · ข้อ​นี้​ตรง​กับ​คอร์ส Java ที่​นับ JUnit Jupiter เป็น​หนึ่ง​ใน​สอง​ใบ​ของ​ตัว​เอง

สิ่ง​ที่​คอร์ส​นี้​ไม่​ใช้ และ​ไป​ต่อ​จาก​ตรง​นี้

หัวข้อ​ที่​มีชื่อ​ว่า “สิ่ง​ที่​คอร์ส​นี้​ไม่​ใช้ และ​ไป​ต่อ​จาก​ตรง​นี้”

เรื่อง​ที่​ตัด​ออก​มี​เหตุผล​ที่​ตรวจ​ได้ และ​สาม​ก้าว​ถัด​ไป​ต่อ​จาก​สิ่ง​ที่​เพิ่ง​เรียน​จบ

Ktor คือ​คำ​ตอบ​ที่​คน​ส่วน​ใหญ่​ค้น​เจอ​เมื่อ​พิมพ์​คำ​ว่า Kotlin กับ HTTP เข้าไป​ด้วย​กัน · คอร์ส​นี้​ไม่​ใช้​มัน ด้วย​เหตุผล​เดียว​กับ​ที่​คอร์ส Java ไม่​ใช้ Jetty

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

สาม​ก้าว​ถัด​ไป​ที่​ต่อ​จาก​บท​นี้​ตรง​ที่สุด

  • virtual thread pinning หลัง JEP 491 — การ​วัด​จริง​ข้าม​หลาย​รุ่น พร้อม​เคส​ที่​ยัง​ตรึง​อยู่​ทุก​รุ่น ซึ่ง​เป็น​ช่อง​ควบคุม​ของ​การ​วัด​ไป​ใน​ตัว
  • coroutines basics ของ JetBrains — Flow, Channel และ​ตัว​จัด​คิว​ตัว​อื่น​ที่​บท​นี้​ไม่​ได้​แตะ
  • คอร์ส Go จาก​ศูนย์ บท​ที่ 8 — ของ​ชิ้น​เดียวกัน​ใน​อีก​ภาษา อ่าน​คู่​กับ​ตาราง​สี่​แถว​ข้าง​บน​แล้ว​คุณ​จะ​เห็น​ราคา​ของ​ทั้ง​สาม​ฝั่ง​พร้อม​กัน

คุณ​เขียน Kotlin เป็น​แล้ว​ใน​ความ​หมาย​ที่​ใช้ได้​จริง ประกาศ​ชนิด​ของ​โดเมน​ที่​ผิด​ไม่​ได้ จัดการ​ความ​ผิด​พลาด​ตาม​กติกา​ของ​ภาษา และ​เปิด service ที่​รับคำ​ขอ​พร้อม​กัน​ได้


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

  • Stability of Kotlin components (เข้า​ถึง 2026-08-28) — ตาราง Kotlin libraries · แถว​ของ kotlinx-coroutines มี​ช่อง Status เป็น Stable และ​ช่อง Status since version เป็น 1.3.0 · หน้า​เดียวกัน​นิยาม Stable ว่า “use it even in most conservative scenarios”
  • Coroutines basics (เข้า​ถึง 2026-08-28) — “While the suspend keyword is part of the core Kotlin language, most coroutine features are available through the kotlinx.coroutines library.” และ “A parent coroutine waits for its children to complete before it finishes. If the parent coroutine fails or gets canceled, all its child coroutines are recursively canceled too.”
  • Dispatchers.IO — kotlinx.coroutines API (เข้า​ถึง 2026-08-28) — “It defaults to the limit of 64 threads or the number of cores (whichever is larger).”
  • JEP 505: Structured Concurrency (Fifth Preview) (เข้า​ถึง 2026-08-28) — Status Closed/Delivered · Release 25 · รอบ​ที่ JDK ของ​คอร์ส​นี้​ได้​รับ
  • JEP 533: Structured Concurrency (Seventh Preview) (Updated 2026/08/19 · เข้า​ถึง 2026-08-28) — Release 27 · รอบ​ล่าสุด​ที่​ประกาศ​แล้ว
  • Thread (Java SE 25) (เข้า​ถึง 2026-08-28) — “Virtual threads are typically user-mode threads scheduled by the Java runtime rather than the operating system.” และ “Virtual threads are not intended for long running CPU intensive operations.”
  • maven-metadata ของ kotlinx-coroutines-core (เข้า​ถึง 2026-08-28) — release เป็น 1.11.0
  • maven-metadata ของ Lincheck (เข้า​ถึง 2026-08-28) — release เป็น 3.7

ตัวเลข​นับ​ทุก​ตัว​ใน​บท​นี้​วัด​เอง​เมื่อ 2026-08-28 บน kotlinc-jvm 2.4.10 กับ OpenJDK 25.0.4.1 บน macOS arm64 ที่​รายงาน availableProcessors เป็น 18 · หน่วย​ที่​ใช้​คือ​จำนวนนับ จำนวน​ไบต์ และ​ข้อความ​ของ compiler ไม่มี​หน่วย​เวลา​สัก​ตัว

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

ข้อ 1 / 3

`chargeAll` ส่งงานเข้า executor ด้วย `pool.submit(...)` · ใบหนึ่งโยน `IllegalStateException` ออกมา แล้วบรรทัดสุดท้ายเรียก `it.get()` ทุกใบ · สิ่งที่ผู้เรียกจับได้คืออะไร