capstone — เปิด HTTP ด้วย JDK เปล่า แล้วเลือกโหมดล้มเหลวที่บรรทัดเดียว
เจ็ดบทที่ผ่านมา orderctl ทำงานทีละใบ อ่านออเดอร์ ตรวจ คิดเงิน แล้วจบ
บทนี้เปลี่ยนสองอย่างพร้อมกัน มันเปิด port ให้คนอื่นยิงเข้ามา และมันคิดเงินหลายใบพร้อมกันได้
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 ที่ไม่ได้อยู่ในภาษา
HTTP อยู่ในกล่องแล้ว และ Kotlin ตัด handler เหลือ lambda
หัวข้อที่มีชื่อว่า “HTTP อยู่ในกล่องแล้ว และ Kotlin ตัด handler เหลือ lambda”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 เป็นมันได้เอง
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 ของตัวเอง · ฝั่งนี้ไม่มีทั้งสามอย่าง
ลงทะเบียน /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-1200ServeMux ของคอร์ส Go รับ pattern แบบ mux.HandleFunc("POST /orders", h)
และตอบ 405 ให้เองเมื่อ method ไม่ตรง · ที่นี่ไม่มีทั้งสองอย่าง
บรรทัดที่ตรวจ method จึงต้องเป็นบรรทัดที่เราเขียนเอง
ตรรกะที่ไม่รู้จัก HTTP เลย
หัวข้อที่มีชื่อว่า “ตรรกะที่ไม่รู้จัก HTTP เลย”ตัวคิดราคารับ string คืน string จึงเทสต์ได้โดยไม่ต้องเปิด port
Quotes.quote ใช้ kotlinx.serialization ตัวเดียวกับบทที่ 3 และได้ความเข้มงวด
ค่าปริยายของมันมาด้วย
package orderctl
import kotlinx.serialization.Serializableimport kotlinx.serialization.json.Json
@Serializabledata class QuoteAsk(val sku: String, val qty: Int, val unitSatang: Long)
@Serializabledata 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 ต้องมากกว่าศูนย์"}QuoteHandler · ตัวรับคำขอที่ปิดช่องเอง
หัวข้อที่มีชื่อว่า “QuoteHandler · ตัวรับคำขอที่ปิดช่องเอง”ความยาวของคำตอบนับเป็นไบต์ ชนิดของมันเป็น
Longและข้อยกเว้นที่หลุดออกไปหายเงียบ
package orderctl
import com.sun.net.httpserver.HttpExchangeimport com.sun.net.httpserver.HttpHandlerimport 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 คือค่าที่บอกว่าไม่มีเนื้อความตามมาเลย · ส่วนคำตอบที่มีเนื้อความ
ต้องบอกความยาวเป็นตัวเลขจริง ซึ่งเป็นที่มาของหัวข้อย่อยถัดไป
.toLong() ไม่ใช่ของประดับ
หัวข้อที่มีชื่อว่า “.toLong() ไม่ใช่ของประดับ”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 contentContent-type: application/json; charset=utf-8Content-length: 56คำตอบก้อนนั้นมี 38 ตัวอักษร แต่กิน 56 ไบต์ เพราะ ชาไทยเย็น เก้าตัวอักษรกินไป 27 ไบต์
เปลี่ยนบรรทัดนั้นเป็น reply.length.toLong() แล้ววัดใหม่ · ปลายสายไม่ได้เนื้อความที่ขาดครึ่ง
มันไม่ได้อะไรเลย
client ได้: java.io.IOException: HTTP/1.1 header parser received no bytesข้อความนั้นไม่มีคำใบ้สักคำว่าต้นเหตุคือหน่วยของความยาว · เซิร์ฟเวอร์เขียนเกินจำนวน ที่ประกาศไว้ แล้วปิดสายทิ้งก่อนที่ header จะออกไปถึงใคร
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 ที่อ่านออก
เปิด port แล้ววัดด้วยจำนวน thread ไม่ใช่เวลา
หัวข้อที่มีชื่อว่า “เปิด port แล้ววัดด้วยจำนวน thread ไม่ใช่เวลา”ไม่เรียก
setExecutorแล้วคำขอทุกใบเดินบน thread เดียว
package orderctl
import com.sun.net.httpserver.HttpHandlerimport com.sun.net.httpserver.HttpServerimport java.net.InetSocketAddressimport 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.ktfun main() { Serve.start(8080, QuoteHandler(Quotes::quote))}เขียนแค่นั้นแล้ว gradle run ยังเรียกมันไม่ได้ เพราะ build.gradle.kts ตั้งปลายทางไว้ที่
orderctl.MainKt ซึ่งเป็น CLI ของบทที่ 7 · ปลายทางของบทนี้จึงต้องย้าย
application { mainClass = "orderctl.ServeKt" }ชื่อ ServeKt มาจากชื่อ file ตามกฎเดียวกับ HelloKt ของบทที่ 1
server.executor = ... คือรูป property ของ Kotlin ที่แปลเป็น setExecutor ของ Java ตรงๆ
· มันเป็นบรรทัดที่เปลี่ยนพฤติกรรมของทั้งเซิร์ฟเวอร์
ยิงคำขอแปดใบพร้อมกันเข้า handler ที่บล็อกอยู่ แล้วนับ thread ที่ลงมือทำงานจริง
| สิ่งที่วัด | ไม่เรียก setExecutor | newVirtualThreadPerTaskExecutor |
|---|---|---|
| thread ที่ต่างกัน | 1 | 8 |
| จำนวนที่ทำงานพร้อมกันสูงสุด | 1 | 8 |
ตัวเลขคู่นี้ไม่ใช่การจับเวลา มันคือการนับ 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
package orderctl
import java.util.concurrent.Executorsimport 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.ExecutionExceptiongetCause java.lang.IllegalStateException: ใบ B ถูกปฏิเสธงานที่ submit แล้วไม่มีใครเรียก get() คือความผิดพลาดที่หายไปตลอดกาล
· ข้อนี้เหมือนคอร์ส Java ทุกตัวอักษร เพราะเป็น ExecutorService ตัวเดียวกัน
· หัวข้อถัดไปจะแสดงว่า coroutine ให้ทางเลือกอีกทางที่ไม่ต้องพึ่งวินัยของคนเขียน
ประตูของ coroutine ปิดอยู่สองชั้น
หัวข้อที่มีชื่อว่า “ประตูของ coroutine ปิดอยู่สองชั้น”
suspendเป็นคำในภาษา แต่ของที่ทำให้มันมีประโยชน์อยู่ในกล่องคนละใบ
ชั้นแรกคือ dependency · import kotlinx.coroutines.runBlocking เปล่าๆ ไม่ผ่าน
$ kotlinc -Werror -Wextra -jvm-target 25 NoDep.kt -d outndNoDep.kt:1:8: error: unresolved reference 'kotlinx'.import kotlinx.coroutines.runBlocking ^^^^^^^NoDep.kt:2:14: error: unresolved reference 'runBlocking'.fun main() = runBlocking { println("ok") } ^^^^^^^^^^^บรรทัดที่ปิดชั้นนี้มีบรรทัดเดียว และมันคือใบเดียวที่ครึ่งหลังของบทนี้เพิ่มเข้ามา
dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") }เอกสารทางการเขียนเส้นแบ่งนี้ไว้เอง
“While the
suspendkeyword is part of the core Kotlin language, most coroutine features are available through thekotlinx.coroutineslibrary.”
ชั้นที่สองคือกฎของภาษา · 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.HttpExchangeimport com.sun.net.httpserver.HttpHandlerimport 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() ของมันเลย
package orderctl
import kotlinx.coroutines.asyncimport kotlinx.coroutines.coroutineScopeimport kotlinx.coroutines.runBlockingimport 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 ที่พิมพ์ ไม่ใช่โครงสร้างคนละแบบ · นี่คือของที่คอร์สนี้มีแล้วอีกสองคอร์สไม่มี
หน้าสถานะของ JetBrains มีตารางชื่อ Kotlin libraries · แถวของ coroutine ทั้งแถวเป็นแบบนี้
| Component | Status | Status since version |
|---|---|---|
| kotlinx-coroutines | Stable | 1.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 เลย
64 เทียบ 500
หัวข้อที่มีชื่อว่า “64 เทียบ 500”
Dispatchers.IOตันที่ 64 ส่วน virtual thread ขึ้นครบทั้ง 500
ทั้งสองเส้นทำงานเดียวกันคืองานที่บล็อกอยู่ 500 ชิ้น · เราไม่จับเวลา เรานับ threadId()
package orderctl
import kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.launchimport kotlinx.coroutines.runBlockingimport java.util.concurrent.ConcurrentHashMapimport 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 = 64virtual thread = 500flowchart 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 ตรงตามที่ประกาศ
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 เขียนเองว่าตัวเองพูดได้แค่ครึ่งแรก · คอร์สนี้อยู่ตำแหน่งที่สาม
| เรื่อง | Go | Java | Kotlin |
|---|---|---|---|
| routing | mux.HandleFunc("POST /orders", h) อ่าน method ให้ และตอบ 405 เอง | createContext จับ prefix ล้วน ต้องเขียนบรรทัดตรวจ method เอง | เหมือน Java เป๊ะ เพราะเป็นเซิร์ฟเวอร์ตัวเดียวกัน แต่ handler เป็น lambda ได้ |
| งานพร้อมกันในกล่อง | goroutine เป็นคำในภาษา | virtual thread มากับ JDK | virtual thread มากับ JDK เหมือนกัน แต่ coroutine ที่เป็นเครื่องมือเรือธงอยู่นอกภาษา |
| การจับ data race | go test -race ชี้บรรทัดให้ และจับเฉพาะที่เกิดจริงตอนรัน | ไม่มีใน toolchain มาตรฐาน | ไม่มีเหมือนกัน · Lincheck 3.7 เป็นเครื่องมือคนละชนิดที่ต้องดึงเพิ่ม |
| ลูกล้มหนึ่งใบ | panic ใน goroutine ที่ไม่มีใครรับ ฆ่าโปรเซสทั้งตัว | submit ที่ไม่มีใคร get() กลืนความผิดพลาดหายไป | เลือกได้ที่บรรทัดเดียว coroutineScope ทำแบบ Go ส่วน supervisorScope ทำแบบ Java |
แถวสุดท้ายคือแถวที่สรุปทั้งบทได้ดีที่สุด · สองภาษาพี่เลือกโหมดความล้มเหลวไว้ให้แล้ว และเปลี่ยนไม่ได้ · Kotlin ย้ายการเลือกนั้นมาไว้ในมือคนเขียน
แต่ราคาของอิสระข้อนั้นอยู่ในแถวที่สอง · Kotlin เป็นภาษาเดียวในสามภาษาที่เครื่องมือ เรือธงของตัวเองไม่ได้อยู่ในภาษา และการหยิบมันมาใช้ทำให้ข้ออ้าง “ไม่เพิ่ม dependency” หมดไป
orderctl ทำอะไรได้แล้วบ้าง
หัวข้อที่มีชื่อว่า “orderctl ทำอะไรได้แล้วบ้าง”แปดบทต่อกันเป็นเครื่องมือตัวเดียว และตลอดทางประกาศ dependency ของโดเมนไว้สองตัว
| บท | สิ่งที่เพิ่มเข้ามา |
|---|---|
| 1 | kotlinc 2.4.10 ที่ติดตั้งแล้ว และโปรแกรมแรกที่รู้ว่า HelloKt มาจากไหน |
| 2 | ชนิดของโดเมนทั้งชุดที่สร้างสถานะผิดไม่ขึ้น |
| 3 | build.gradle.kts ที่เขียนเองทุกบรรทัด และ JSON ที่ตรวจคีย์ให้ทั้งสองทาง |
| 4 | ยอดรวม การสรุปตามหมวด และเส้นแบ่งของ Sequence |
| 5 | กฎส่วนลดที่ต่อเพิ่มได้ และกับดักของ by |
| 6 | การรายงานทุกข้อที่ผิดพร้อมกัน และการปิด resource ที่ยังเห็นความผิดพลาดตอนปิด |
| 7 | บรรทัดคำสั่ง รหัสออก state บน disk และเทสต์ชุดแรกที่ชื่อเป็นภาษาไทย |
| 8 | POST /quote ที่เปิดด้วย JDK เปล่า และการคิดเงินหลายใบพร้อมกัน |
หน้าต่างแรก เปิด port
$ gradle -q runorderctl ฟังที่ 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
suspendkeyword is part of the core Kotlin language, most coroutine features are available through thekotlinx.coroutineslibrary.” และ “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()` ทุกใบ · สิ่งที่ผู้เรียกจับได้คืออะไร