Flutter | มารับมือกับ CPU-bound ด้วย Isolates เบื้องต้นกัน

Created on Jul 03, 2020
เรามาเริ่มต้นด้วยทฤษฎีกันก่อน หรือใครอยากดูวิธีใช้งาน compute ก็ข้ามไปที่ มาเริ่มลงมือกัน ได้เลย
ภาษา Dart มีวิธีรับมือกับ concurrency ในเวลาเขียนโค๊ดด้วยการใช้ Async/await หรือ Future ซึ่งถ้าเราได้ลองสังเกตดู เราจะใช้ตอนที่ต้องรอ IO-bound เช่น การรอข้อมูลจากในเครื่อง และไฟล์ดาวน์โหลดจากอินเตอร์เน็ต ซึ่งไม่ได้จำเป็นต้องใช้การคำนวนจาก CPU
แล้วการทำงานที่ต้องใช้ CPU เป็นหลัก หรือ CPU-bound ละ เช่น การ parse JSON ใหญ่ๆ หรือต้องคำนวน encryption ที่เราเขียนขึ้นเองบน Dart ถ้าเรารอเฉยๆ การคำนวนคงไม่เสร็จแน่ แต่ถ้าเรารันดื้อๆเลย UI เราค้างแน่นอน เพราะปกติแล้วโค๊ดทุกอย่างของเราจะรันบน Event loops (UI thread) ว่าง่ายๆ ถ้าจู่ๆเรารัน CPU-bound มันจะไป บล็อค Event loops ซึ่งการคำนวน UI เรารันอยู่บนนั้น ดังนั้นเราจึงมีวิธีรับมือโดยการใช้ Isolates
ลองดูจาก Flutter’s official ข้างล่าง หรืออ่านคำอธิบายด้านล่างต่อก็ได้
ก่อนอื่นมาอ้างอิงคำอธิบายจากใน Docs ก่อน
Concurrent programming using isolates: independent workers that are similar to threads but don’t share memory, communicating only via messages.
หลักการของ Isolates จะคล้าย threads คือมีความเบา สามารถสร้างขึ้น และทำลายลงได้ง่าย ที่แตกต่างคือจะแยกส่วนของหน่วยความจำชัดเจน ไม่มีการส่ง reference ไปมา ดังนั้นวิธีส่งข้อมูล จะทำโดยการส่งข้อความแทน
ถ้าอ่านมาถึงจุดนี้แล้ว งงกับคำศัพท์มากมาย ลองเสริชกลูเกิลเกี่ยวกับคำเหล่านี้ อาจช่วยให้เข้าใจมากขึ้น “thread vs process” “pass by reference vs pass by value”
หมายเหตุ Isolates != Thread แต่เพื่อให้อธิบายง่ายขึ้น เรา จะใช้คำว่า Thread ซึ่งจริงๆแล้ว มันคือ Isolates นะ 👌
เราอยู่กับภาคทฤษฎีเริ่มเยอะแล้ว ตัดเข้าไปที่ภาคปฎิบัติกันดีกว่า
เราจะมาเริ่มใช้ Isolate แบบง่ายๆ โดยจำลองการคำนวน Fibonacci แบบ recursive ในขณะเดียวกัน เราจะรันอนิเมชั่นไปด้วยเพื่อแสดงถึงการ บล็อค Event loops

มาเริ่มลงมือกัน

หน้าตาแอปเรา เอาแบบเรียบง่ายที่สุด มีหน้าเดียว ตรงกลางมีไอคอนอนิเมชั่น loading ถัดลงมาโชว์ผลลัพธ์จากการคำนวณ และปุ่มด้านล่าง 3 ปุ่ม สำหรับรัน Fibonacci แบบใช้ กับ ไม่ใช้ Isolate และปุ่ม Reset

มาขั้นเตรียมตัวกันเล็กน้อย

เชื่อว่า ขั้นนี้น่าจะทำเป็นกันทุกคนแล้ว คือ สร้างโฟล์เดอร์โปรเจคของเราก่อน
เปิด shell ที่ท่านใช้ ไม่ว่าจะเป็น bash, cmd, powershell, zsh หรืออันอื่น แล้วรัน
flutter create flutter_simple_isolate  
cd flutter_simple_isolate
หลังจากเตรียมตัวเสร็จ

เริ่มโค๊ดกัน

เราจะอยู่แต่กับไฟล์
**lib/main.dart**
เพียงอย่างเดียวเลย
มาตกแต่งกันหน่อยก่อน
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primaryColor: Colors.white,
visualDensity: VisualDensity.adaptivePlatformDensity,
backgroundColor: Colors.grey[900],
scaffoldBackgroundColor: Colors.grey[900],
accentColor: Colors.white,
textTheme: Theme.of(context)
.textTheme
.apply(bodyColor: Colors.white, displayColor: Colors.white)),
home: MyHomePage(title: 'Flutter Simple Isolate'),
);
}
}
view raw main.dart hosted with ❤ by GitHub
เราจะแก้สีที่ theme โดยการส่ง ThemeData ที่เราต้องการเขาไป รอบนี้เล่นโทนขาวดำ

ต่อมา แก้หน้าตาจาก Counter app ให้กลายเป็นหน้าตาของเรา

class _MyHomePageState extends State<MyHomePage> {
int fiboResult = 0; // ตัวแปรเก็บค่าไว้แสดงผลจากการคำนวน
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Transform.scale(scale:2.5,child: CircularProgressIndicator()),
SizedBox(height: 72.0),
Text(
'Fibonacci 40th',
),
SizedBox(height: 8.0),
Text(
'\$fiboResult',
style: Theme.of(context).textTheme.headline4,
),
SizedBox(height: 8.0),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
RaisedButton(
onPressed: null,
child: Text("Fibo non-blocking")),
SizedBox(width: 16),
RaisedButton(
onPressed: null,
child: Text("Fibo blocking")),
],
),
RaisedButton(
onPressed: null,
child: Text("Reset"))
],
),
));
}
}
view raw main.dart hosted with ❤ by GitHub
หน้าตาเราพร้อมแล้ว แต่ปุ่มยังไม่ได้ใส่การทำงานลงไป มาอธิบายกับชื่อกันหน่อยก่อนไปต่อ
ปุ่มทั้ง 3 ของเรา
  1. Fibo non-blocking จะเป็นปุ่มที่เราคำนวน Fibonacci บน Isolate แล้วอัพเดทค่า fiboResult
  2. Fibo blocking จะเป็นปุ่มที่เราคำนวน Fibonacci บน Event loop แล้วอัพเดทค่า fiboResult
  3. Reset จะรีค่า fiboResult กลับเป็น 0
เรามาสร้างฟังก์ชั่นคำนวนค่า Fibonacci กัน ให้วางไว้ด้านนอก class ทุกอัน หรือ ทำให้เป็น static method ดังนั้น เอาไว้ด้านล่างสุดของไฟล์เลยก็ได้ เดี๋ยวมาอธิบายเหตุผลอีกที
int fibonacci(int n) {
/// fibonacci recursive
assert(n >= 1);
if (n == 1)
return 0;
else if (n == 2) return 1;
return fibonacci(n - 1) + fibonacci(n - 2);
}
view raw main.dart hosted with ❤ by GitHub

กลับมาต่อกับปุ่มที่ 2 และ 3 ก่อนเพราะ ง่ายสุด

เรากลับมาใส่ฟังก์ชั่น ใน onPressed ของทั้งคู่
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
RaisedButton(
onPressed: null,
child: Text("Fibo non-blocking")),
SizedBox(width: 16),
RaisedButton(
onPressed: () {
setState(() {
fiboResult = fibonacci(40);
});},
child: Text("Fibo blocking")),
],
),
RaisedButton(
onPressed: () {
setState(() {
fiboResult = 0;
});
},
child: Text("Reset"))
view raw main.dart hosted with ❤ by GitHub
เราสามารถเรียก
**setState((){})**
แล้วอัพเดทค่า fiboResult ตามหน้าที่ของปุ่ม ซึ่งเมื่อเราลองกด Fibo blocking จะได้ผลลัพธ์ตามด้านล่าง
จะเห็นได้ว่ามันทำให้ UI เราค้างขณะที่กำลังคำนวณ(มันค้างจริงๆนะ ไม่ใช่ไฟล์ gif มีปัญหาหรอกนะ 😂) เพราะเรากำลังคำนวณบน Event loop ซึ่งเป็น thread ที่ใช้ในการคำนวน UI นั่นเอง

ดังนั้น เรามาทำปุ่มที่ 1 ต่อ

ก่อนเราจะไปแก้ ฟังก์ชั่น เราจำเป็นต้อง import package เข้ามาก่อน เอาไว้บนสุดเลย
import 'package:flutter/foundation.dart';
แก้ onPressed เป็นตามนี้
RaisedButton(
onPressed: () async {
fiboResult = await compute(fibonacci, 40);
setState(() {});
},
child: Text("Fibo non-blocking")),
view raw main.dart hosted with ❤ by GitHub
เราทำการสร้าง Isolates แบบง่าย โดยใช้คำสั่ง
**compute()**
แล้วส่งฟังก์ชั่น
fibonacci
กับ
argument — 40
เข้าไป เพียงเท่านี้เราก็จะไปรันบน thread แยกแล้ว แล้วค่าที่ได้กลับมาก็จะเป็น Future รอค่าหลังจากคำนวณเสร็จ นำ async/await เข้ามาใส่ให้เรียบร้อย Event Loop เราก็สามารถไปรันอย่างอื่นรอได้แล้ว
Isolates หรือที่เราใช้
compute()
จะสร้าง thread ใหม่ที่มีโค๊ดเหมือนกับ UI thread(Event Loop) แล้วกระโดดไปรันที่ฟังก์ชั่นที่เราระบุไว้ จึงเป็นสาเหตุให้ฟังก์ชั่นต้องอยู่นอก Class (หรือตามเอกสารจะเรียกว่า top-level function) หรือจะเรียกฟังก์ชั่นที่เป็น static method ก็ได้
มาดูผลลัพธ์กัน
เพียงเท่านี้ เราก็สามารถรัน CPU-bound แบบ non-blocking ได้แล้ว
มาดูโค๊ดทั้งหมดกันอีกที
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primaryColor: Colors.white,
visualDensity: VisualDensity.adaptivePlatformDensity,
backgroundColor: Colors.grey[900],
scaffoldBackgroundColor: Colors.grey[900],
accentColor: Colors.white,
textTheme: Theme.of(context)
.textTheme
.apply(bodyColor: Colors.white, displayColor: Colors.white)),
home: MyHomePage(title: 'Flutter Simple Isolate'),
);
}
}
class MyHomePage extends StatefulWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
int fiboResult = 0;
Future<int> fibonacciNoneBlocking() async {
/// calculate fibonacci on Isolate
/// return a Future
return await compute(fibonacci, 40);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Transform.scale(scale: 2.5, child: CircularProgressIndicator()),
SizedBox(height: 72.0),
Text(
'Fibonacci 40th',
),
SizedBox(height: 8.0),
Text(
'\$fiboResult',
style: Theme.of(context).textTheme.headline4,
),
SizedBox(height: 8.0),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
RaisedButton(
onPressed: () async {
fiboResult = await compute(fibonacci, 40);
setState(() {});
},
child: Text("Fibo non-blocking")),
SizedBox(width: 16),
RaisedButton(
onPressed: () {
setState(() {
fiboResult = fibonacci(40);
});
},
child: Text("Fibo blocking")),
],
),
RaisedButton(
onPressed: () {
setState(() {
fiboResult = 0;
});
},
child: Text("Reset"))
],
),
));
}
}
int fibonacci(int n) {
/// fibonacci recursive
assert(n >= 1);
if (n == 1)
return 0;
else if (n == 2) return 1;
return fibonacci(n - 1) + fibonacci(n - 2);
}
view raw main.dart hosted with ❤ by GitHub
จบแล้วอีกหนึ่งบทความ ครั้งนี้เราได้เรียนรู้วิธีการทำ Concurrency บน dart โดยที่เราใช้ตัวช่วยจาก Flutter หรือที่เราใช้
compute()
นั้นทำให้เราเรียกใช้ Isolates ได้อย่างกระชับ เพราะถ้าเราใช้ Isolates โดยตรง เราจำเป็นต้องเตรียมโค๊ดมากกว่านี้ แต่ก็แลกกับการที่เราสามารถดึงความสามารถของ Isolates มาใช้ได้เต็มที่ เพราะจริงๆแล้ว เราสามารถส่งข้อมูลไปมาระหว่าง Event loop กับ Isolates ด้วย SendPort กับ ReceivePort แต่สำหรับ Isolates เบื้องต้นก็จบเพียงเท่านี้ก่อน ไว้เจอกันโอกาสหน้าครับ
เช่นเคย Source Code แต่ในนั้นก็แถมเวอร์ชั่นใช้ Isolate โดยตรงไว้ด้วย ลองหาฟังก์ชั่น
fibonacciWithIsolate
ใน
main.dart
ดู

แนะนำเรื่องถัดไป