백그라운드 아이솔레이트 및 네이티브 메모리 관리
메인 아이솔레이트 외부에서 FFI 호출을 실행하고 네이티브 할당을 관리해 메모리 누수를 방지합니다.
백그라운드 아이솔레이트 및 네이티브 메모리 관리은(는) CoddyKit의 무료 Flutter Mobile Development 강의입니다. 이것은 4개 중 4번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 Flutter Mobile Development 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. Flutter Mobile Development 강의에는 총 4개의 강의가 포함되어 있습니다.
이 강의의 일부는 아직 번역되지 않았으며 영어로 표시됩니다.
Why FFI Belongs Off the Main Isolate
Dart's FFI lets you call native C functions directly, but those calls run synchronously on whatever isolate invokes them. If a native function is slow (image decoding, crypto, parsing a large buffer), running it on the main isolate blocks the UI thread and drops frames.
- The Flutter UI runs on the root (main) isolate; jank appears when it stalls more than ~16ms.
- Native code has no awareness of Dart's event loop. A 200ms C call freezes the app.
- The fix: run heavy FFI work on a background isolate so the main isolate stays free to paint.
This lesson covers spawning isolates, sharing native pointers safely, and freeing native memory without leaks.
Isolates vs Threads: The Memory Model
A Dart isolate is an independent worker with its own memory heap and event loop. Unlike OS threads, isolates do not share Dart objects — they communicate by passing messages over ports.
- Each isolate has its own GC heap; you cannot pass a normal Dart object by reference between them.
- But native memory is different: a
Pointer<T>fromdart:ffiis just an integer address into the process's shared native heap. - That address is valid in any isolate of the same process — so FFI pointers can be shared across isolates, unlike Dart objects.
This distinction is the key to coordinating FFI work between isolates: you pass the address, not the object.
The Easy Win: Isolate.run
For a one-shot heavy computation, Isolate.run (Dart 2.19+) spawns a short-lived isolate, runs your closure, returns the result, and tears the isolate down automatically. It is the simplest way to move work off the main isolate.
- The closure must be a top-level or static function reference, or a closure that only captures sendable values.
- The return value is copied back to the calling isolate.
- Perfect for a single FFI-heavy operation like hashing a file.
import 'dart:isolate';
int slowFib(int n) {
if (n < 2) return n;
return slowFib(n - 1) + slowFib(n - 2);
}
Future<void> main() async {
// Runs slowFib on a fresh background isolate; UI isolate stays free.
final result = await Isolate.run(() => slowFib(38));
print('fib(38) = ' + result.toString());
}Allocating Native Memory with malloc
The package:ffi library exposes malloc (and calloc) allocators that wrap the C heap. You allocate a typed pointer, write to it, hand it to native code, and you are responsible for freeing it.
malloc<Uint8>(count)returns aPointer<Uint8>tocountbytes.- The Dart GC does not manage this memory — forgetting
malloc.freeleaks it permanently. - Wrap usage in
try/finallyso the free runs even if native code throws.
import 'dart:ffi';
import 'package:ffi/ffi.dart';
void main() {
final buffer = malloc<Uint8>(4);
try {
final bytes = buffer.asTypedList(4);
bytes.setAll(0, [10, 20, 30, 40]);
print('first byte = ' + buffer.value.toString());
print('sum = ' + bytes.reduce((a, b) => a + b).toString());
} finally {
malloc.free(buffer); // never skip this
}
}Passing a Pointer Across an Isolate Boundary
Because a native pointer is an integer address, you can send its address field to a background isolate, then reconstruct the typed pointer there with Pointer.fromAddress.
- Send
ptr.address(a plainint) — it is sendable over aSendPort. - On the receiving isolate, rebuild it:
Pointer<Uint8>.fromAddress(addr). - Ownership rule: decide which isolate frees the memory. A common pattern is the owner allocates, the worker only reads/writes, and the owner frees after the worker signals completion.
import 'dart:ffi';
import 'dart:isolate';
import 'package:ffi/ffi.dart';
int sumOnWorker(int address, int length) {
final ptr = Pointer<Uint8>.fromAddress(address);
final list = ptr.asTypedList(length);
return list.fold<int>(0, (a, b) => a + b);
}
Future<void> main() async {
final buf = malloc<Uint8>(5);
try {
buf.asTypedList(5).setAll(0, [1, 2, 3, 4, 5]);
// Owner (main) keeps ownership; worker only reads via the address.
final total = await Isolate.run(() => sumOnWorker(buf.address, 5));
print('sum from worker = ' + total.toString());
} finally {
malloc.free(buf); // owner frees after worker is done
}
}Loading the Native Library Per Isolate
A DynamicLibrary handle and the function pointers you look up from it are not sendable across isolates. Each isolate that calls native code must open its own DynamicLibrary and resolve its own symbols.
- The underlying
.so/.dylibis loaded once into the process;DynamicLibrary.openin another isolate just gets a fresh handle to the same loaded image. - Cache the looked-up function in an isolate-local variable; do not try to pass a Dart
Functionobtained fromlookupFunctionto another isolate. - On Android the library name is like
libnative.so; on iOS/macOS preferDynamicLibrary.process()for statically linked symbols.
import 'dart:ffi';
import 'dart:io' show Platform;
typedef NativeSum = Int32 Function(Int32 a, Int32 b);
typedef DartSum = int Function(int a, int b);
DynamicLibrary openLib() {
if (Platform.isAndroid) return DynamicLibrary.open('libnative.so');
if (Platform.isIOS || Platform.isMacOS) return DynamicLibrary.process();
return DynamicLibrary.open('libnative.so');
}
// Call this INSIDE each isolate that needs native access.
DartSum resolveSum() {
final lib = openLib();
return lib.lookupFunction<NativeSum, DartSum>('native_sum');
}A Long-Lived Worker Isolate with Ports
For repeated FFI calls, spawning a new isolate each time is wasteful. Instead, spawn one persistent worker with Isolate.spawn and stream requests/responses over ReceivePort/SendPort.
- The worker opens its native library once on startup and reuses the resolved functions.
- The main isolate sends a request record (e.g. address + length + a reply port); the worker does the FFI call and replies.
- This amortizes library-open cost and keeps the main isolate responsive.
import 'dart:isolate';
Future<void> main() async {
final ready = ReceivePort();
await Isolate.spawn(_worker, ready.sendPort);
final SendPort toWorker = await ready.first as SendPort;
final reply = ReceivePort();
toWorker.send([21, reply.sendPort]);
final result = await reply.first;
print('worker doubled => ' + result.toString());
}
void _worker(SendPort initial) {
final port = ReceivePort();
initial.send(port.sendPort); // hand back our inbound port
port.listen((msg) {
final value = msg[0] as int;
final SendPort reply = msg[1] as SendPort;
reply.send(value * 2); // stand-in for a real FFI call
});
}Calling Back into Dart from Native Threads
Sometimes native code (running on its own C thread) needs to notify Dart. You cannot call a Dart function directly from an arbitrary native thread — instead use NativeCallable.listener, which marshals the call onto the isolate that created it via its event loop.
NativeCallable.isolateLocal— synchronous, only callable from the same isolate's thread.NativeCallable.listener— asynchronous and thread-safe; safe to invoke from any native thread, the callback is queued to the owning isolate.- Call
close()on theNativeCallablewhen done, or it keeps the isolate alive and leaks the trampoline.
import 'dart:ffi';
// C signature: void (*)(Int32) the native side stores and calls later.
typedef ProgressNative = Void Function(Int32);
void onProgress(int percent) {
print('native reported ' + percent.toString() + '%');
}
void setupCallback() {
final callback = NativeCallable<ProgressNative>.listener(onProgress);
// Pass callback.nativeFunction to your C registration function.
// When finished, release it:
// callback.close();
print('callback ptr = ' + callback.nativeFunction.address.toString());
}Auto-Freeing with NativeFinalizer
Manual try/finally works for scoped allocations, but for native resources tied to the lifetime of a Dart object, use a NativeFinalizer. It runs a native free function when the Dart object becomes unreachable and is garbage-collected.
- Attach the Dart wrapper to the finalizer with a
tokenpointer to free. - The finalizer is best-effort — it is not guaranteed to run promptly (or at all on abrupt exit), so it is a safety net, not a substitute for explicit cleanup.
- Provide an explicit
dispose()anddetachthe finalizer there to avoid a double free.
import 'dart:ffi';
import 'package:ffi/ffi.dart';
final NativeFinalizer _finalizer = NativeFinalizer(malloc.nativeFree);
class NativeBuffer implements Finalizable {
final Pointer<Uint8> ptr;
bool _disposed = false;
NativeBuffer(int size) : ptr = malloc<Uint8>(size) {
_finalizer.attach(this, ptr.cast(), detach: this);
}
void dispose() {
if (_disposed) return;
_disposed = true;
_finalizer.detach(this); // prevent double free
malloc.free(ptr);
}
}Ownership Discipline Across Isolates
The most common native leak in multi-isolate FFI code is unclear ownership: two isolates each think the other will free a buffer, or both free it (a crash). Establish a contract.
- Single owner: exactly one isolate allocates and frees. Others receive a borrowed address and must not free.
- Hand-off: if ownership transfers, the sender must not touch or free the pointer after sending the address.
- Lifetime: the owner must keep the memory alive until it has confirmation (a reply message) that all borrowers are done — never free while a worker may still read it.
Document the contract in code comments; race conditions here surface as use-after-free crashes that are hard to reproduce.
Detecting and Diagnosing Native Leaks
Native heap leaks do not show up in Dart's memory view because the Dart GC never tracked them. You need native tooling and disciplined patterns.
- Use Android Studio's native memory profiler or Xcode Instruments (Allocations / Leaks) to watch the C heap grow.
- In tests, wrap allocations and assert that every
mallocis matched by afree— a counting allocator wrapper helps. - Run a tight loop of your FFI operation; if RSS climbs without leveling off, you have a leak.
- Prefer
callocwhen you need zeroed memory so stale bytes don't mask bugs.
import 'dart:ffi';
import 'package:ffi/ffi.dart';
int _live = 0;
Pointer<Uint8> track(int n) {
_live++;
return malloc<Uint8>(n);
}
void release(Pointer<Uint8> p) {
_live--;
malloc.free(p);
}
void main() {
for (var i = 0; i < 1000; i++) {
final p = track(64);
release(p);
}
// Must be zero; non-zero means a leak path skipped release().
print('outstanding allocations = ' + _live.toString());
}Quick Check: Sharing FFI Memory Between Isolates
You allocate a Pointer<Uint8> on the main isolate and need a background isolate to read it via FFI. What is the correct, safe way to share it?
Recap: Safe Background FFI
You now have a complete mental model for running FFI off the main isolate without leaking native memory:
- Move heavy native work off the UI isolate with
Isolate.runfor one-shots or a persistentIsolate.spawnworker for repeated calls. - Share native memory by address — send
ptr.address, rebuild withPointer.fromAddress; never send Pointer or DynamicLibrary objects. - Open the native library per isolate; each worker resolves its own function pointers once on startup.
- Free every allocation:
try/finallyfor scoped use,NativeFinalizeras a GC-time safety net, and explicitdispose()withdetach. - Enforce single-owner ownership across isolates and keep memory alive until borrowers confirm completion.
- Use
NativeCallable.listenerfor thread-safe callbacks from native threads, andclose()them when done. - Profile with native tools (Instruments / Android native profiler) since Dart's GC view cannot see C-heap leaks.
자주 묻는 질문
“백그라운드 아이솔레이트 및 네이티브 메모리 관리” 강의는 무료인가요?
네 — “백그라운드 아이솔레이트 및 네이티브 메모리 관리” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Flutter Mobile Development 강의 전체를 잠금 해제할 수 있습니다. Flutter Mobile Development 강의에는 총 4개의 강의가 포함되어 있습니다.
“백그라운드 아이솔레이트 및 네이티브 메모리 관리”에서 뭘 배우나요?
메인 아이솔레이트 외부에서 FFI 호출을 실행하고 네이티브 할당을 관리해 메모리 누수를 방지합니다. 브라우저에서 직접 실행하는 실습 코드로 Flutter Mobile Development을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.
Flutter Mobile Development을(를) 시작하는 데 경험이 필요한가요?
사전 경험은 필요하지 않습니다. CoddyKit의 Flutter Mobile Development은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 4번째 강의입니다.
“백그라운드 아이솔레이트 및 네이티브 메모리 관리” 강의는 얼마나 걸리나요?
대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.
이 Flutter Mobile Development 강의에서 코드를 작성하고 실행할 수 있나요?
네. 모든 Flutter Mobile Development 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.
이 강의의 모든 강의
- dart:ffi로 C 라이브러리 호출하기
- Pigeon을 활용한 타입 안전 플랫폼 채널
- iOS 및 Android용 사용자 지정 플랫폼 플러그인 작성
- 백그라운드 아이솔레이트 및 네이티브 메모리 관리