Từ Android source code đến binary-code. Lý do iOS nhanh hơn Android.
Bài đăng này đã không được cập nhật trong 3 năm
Xin chào mọi người, bài viết này ngoài diễn giải quá trình từ "Source code → Binary-code" thì mình còn trình bày cái nhìn tổng quát về quá trình 1 ứng dụng Android sẽ được thực thi như thế nào. Nội dung cũng sẽ trình bày khá nhiều thuật ngữ mà các bạn từng nghe tới nhưng vẫn chưa tìm hiểu, hoặc còn nhầm lẫn về nó. Bài viết về kiến thức hàn lâm nên khá nhiều chữ và nếu có sai sót mong các bạn góp ý nha. Giờ mình xin phép được bắt đầu !
I. Source code → Machine-code
1. Quá trình tạo file .apk
Mình sẽ giải thích chi tiết sơ đồ này:
- Android source code: Nó là những file
.java/.kt
mà bạn viết trong project Android. - Javac( Java compiler): Đây là trình biên dịch của java, nó sẽ dịch các file
.java/.kt
thành các file.class
chứa java-bytecode. Khi bạn nhấn Run/BuildAPK thì thằng này sẽ được chạy, thằng này chạy xong thì sẽ đến lượt Dex compiler. - Dex( Dalvik Excutable) compiler: Thằng này cũng là trình biên dịch, nó sẽ dịch java byte-code trong các file
.class
thành dalvik byte-code và lưu vào 1 file.dex
duy nhất. File.dex
này cùng với folderres
, fileAndroidManifest.xml
cùng 1 số thành phần nữa sẽ được nén lại thành.apk
.
Trước version 5.0 thì Android sử dụng DVM(Dalvik virtual machine) để thực thi ứng dụng, nhưng từ sau Android 5.0 thì chuyển sang sử dụng Android Runtime(ART). Vì vậy thay vì giải thích Dalvik thì mình sẽ giải thích Android Runtime(ART). Còn Machine-code thì vẫn giữ nguyên nhé, vì CPU nó có hiểu được cái gì khác ngoài machine-code đâu (Machine-code khác binary-code, mình sẽ giải thích ở dưới).
2. Quá trình cài đặt .apk và thực thi ứng dụng
Như mình đã nói ở trên, Android bây giờ sử dụng ART. ART ngoài sử dụng Just-in-time (JIT) compilation như Dalvik thì còn sử dụng thêm Ahead-of-time(AOT) compilation và Profile-guided compilation, sự kết hợp giữa các chế độ biên dịch này sẽ giúp ứng dụng Android cải thiện được hiệu suất so với sử dụng Dalvik. Các chế độ biên dịch được cấu hình, kết hợp ra sao tuỳ thuộc vào hãng sản xuất điện thoại Android. Giải thích thuật ngữ:
- JIT: Là 1 trình biên dịch(compiler), nó dịch dalvik byte-code trong
.dex
→ machine-code với cơ chế theo từng method(tức là theo khối code lớn). - AOT: Là 1 trình biên dịch, tên của nó có ý nghĩa là biên dịch trước thời gian ứng dụng được thực thi. Nó được chạy dưới nền.
- Profile-guided compilation: Nó có chức năng hướng dẫn chạy mã trong quá trình ứng dụng thực thi.
- Interpreted(Giải thích trước cho phần ở dưới sẽ nhắc đến): Thông dịch, trình thông dịch hoạt động với cơ chế dịch từng dòng trong tệp input rồi thực thi ngay dòng output đó, và rồi lại cứ lặp quá trình dịch với các dòng tiếp theo. Lưu ý biên dịch/thông dịch là những khái niệm không đc xác định rõ ràng về lý thuyết, bởi nếu biên dịch mà chạy ở "realtime" thì cũng được gọi là thông dịch. Ở chấm đầu dòng ngay dưới đây bạn sẽ thấy thằng JIT nó chạy "realtime" cùng lúc với thằng Interpreted.
Ở đây mình sẽ trình bày sự hoạt động của ART trên thiết bị Pixel :
- Khi cài đặt file
.apk
, thì ở “1 vài lần chạy đầu tiên” ART sẽ chạy với cơ chế Interpreted và JIT. Tại sao lại chạy với 2 cơ chế này? Vì là để tối ưu khởi động ứng dụng nhanh(dùng Interpreted) và trong quá trình tương tác cũng phải nhanh(dùng JIT). Khi ứng dụng bắt đầu chạy thì có hàng ngàn methods được gọi, lúc này mà chạy với JIT thì ứng dụng mất nhiều thời gian khởi động hơn(vì nó dịch theo block lớn mà), nên lúc này dùng Interpreted sẽ nhanh hơn( vì nó dịch được dòng nào là thực thi luôn, nghĩa là tức thời). Còn khi đã qua giai đoạn khởi động, ứng dụng sẽ chạy hỗn hợp cả 2, tức là phần đầu của method sẽ chạy với Interpreted, cùng lúc này JIT cũng sẽ dịch method đó, khi JIT dịch xong thì Interpreted dừng lại, method vừa đc JIT dịch xong sẽ thực thi luôn đống machine-code này, k cần Interpreted dịch nữa → Nhanh. - Khi thiết bị ở chế độ rảnh và đang sạc PIN thì một Compilation Daemon(Trình biên dịch nền) sẽ chạy AOT(có 1 công cụ là dex2oat) để biên dịch những methods được sử dụng thường xuyên( dựa trên cấu hình từ “1 vài lần chạy đầu tiên") → Machine-code và lưu nó trong file
.oat
. Machine-code là mã mà CPU thực thi được, vậy nên khi chạy với machine-code trong file.oat
thì sẽ nhanh hơn do không mất thời gian dịch dalvil byte-code → machine-code. Hãng sản xuất khác thì họ có thể cấu hình sẽ chạy AOT luôn khi install app chẳng hạn, nhưng điều này sẽ làm thời gian install app khá lâu. - Khi ứng dụng chạy các lần sau nếu file
.oat
có sẵn, ART sẽ sử dụng trực tiếp chúng, nếu không ART sẽ chạy với cơ chế hỗn hợp để thực thi file.dex
. JIT nó luôn ghi lại "dấu chân" những method mà nó dịch nhiều lần và lưu machine-code của method đó ở 1 nơi gọi là Code Cache. Xem hình dưới, cold code là những method mới, hot code là những method đã được dịch nhiều lần và JIT ghi lại log. Khi chạy 1 method ở trong.dex
ART sẽ check xem nó có trong JIT logging hay không, nếu có thì lấy mã trong cache ra chạy luôn(mũi tên hot code), nếu không thì chạy kiểu hỗn hợp(cold code).
- Những methods nào mà JIT ghi lại log sẽ được lưu trong 1 thư mục hệ thống mà chỉ có ứng dụng đó đọc được( cấu hình từ “1 vài lần chạy đầu tiên" nhắc ở trên là lấy từ thằng log này). AOT sẽ phân tích tệp đó để điều khiển quá trình biên dịch ra
.oat
của chính nó và dĩ nhiên file.oat
này để lần tới ứng dụng chạy nhanh hơn. Quá trình hướng dẫn lúc nào chạy.oat
, lúc nào chạy.dex
gọi là Profile-guided compilation. Mỗi khi điện thoại ở chế độ rảnh và sạc PIN thì AOT lại được chạy để cập nhật file.oat
. - Điều này thể hiện rõ nét nhất khi ta có 1 screen(fragment/activity) "nặng". Khi bật screen đó lần đầu ta sẽ thấy hơi giật/lag nếu máy yếu vì khi này là chạy với cơ chế thông dịch. Nhưng back lại sau đó bật lại screen đó 1 lần nữa. Ta sẽ thấy screen đó mượt hơn vì lúc này là chạy trực tiếp machine-code.
Note: File .oat
tối ưu này ở trước Android 5.0 thì được gọi là .odex
(Optimized
Dex). Thực chất thứ trong .odex
là object-code được lưu với định dạng file .ELF
(chi tiết tại đây)
nhưng Android gọi nó là .oat
và về mặt kĩ thuật thì object-code là 1 phần của
machine-code chưa được liên kết thành 1 chương trình cụ thể. Nên trong context
này có thể coi object-code và machine-code là 1. Tham khảo sơ đồ so sánh Dalvik
với ART tại đây.
ART ngoài những ưu điểm trên thì Garbage Collector(GC) của ART cũng tốt hơn của Dalvik, có thể kể đến như thời gian tạm dừng giảm từ 2 xuống 1, nén bộ nhớ HEAP khi app vào nền để giảm RAM. Tuy là GC tốt hơn nhưng khi code các bạn vẫn nên để ý cách tạo Objects, chạy vòng lặp,,... để giảm công việc cho GC từ đó tối ưu hiệu suất. Xem thêm Performance tips.
==> Vậy tại sao không dùng AOT để biên dịch hết luôn .dex
sang machine-code lưu trong .oat
để chạy cho nhanh? Không, bởi vì:
- Nếu dịch hết ra
.oat
thì kích thước ứng dụng sẽ tăng lên rất nhiều. Điều này tạo nên ưu điểm của Android về app size. App size của Android luôn nhỏ hơn app size iOS khá nhiều, bởi vì iOS biên dịch hết Swift ra machine-code → app size lớn. - Lý do nữa là mình đọc trong đây có đề cập tới AOT và JIT có thể tạo mã không giống nhau dẫn tới có thể AOT ở 1 vài trường hợp tạo ra mã không tương thích với CPU, mà mỗi hãng Android sử dụng 1 loại chip khác nhau (Snapdragon, Exynos, Kirin, Meditek,..) → kiến trúc CPU khác nhau(Mọi người hay nói Android bị phân mảnh thì 1 phần là do điều này). Các kiến trúc này chỉ có 1 phần machine-code giống nhau (chính xác phải là ISA, mình sẽ giải thích ở dưới), nên chỉ AOT những dalvik-bytecode mà biên dịch ra đc machine-code hợp lệ trên nhiều kiến trúc CPU.
II. Machine-code → Binary-code
1. Machine-code → Binary-code
Machine-code(còn gọi là Native-code) khác binary-code. Machine-code được thực thi trực tiếp trên CPU nhưng nó k phải là binary-code. Mã nguồn của chúng ta sau khi đc biên dịch/ thông dịch sẽ trở thành machine-code instructions(hay gọi tắt là machine-code) là đại diện cho các lệnh của CPU(là mẫu bit, mẫu bit mới là binary-code) . Như vậy cách gọi đúng output của compiler là machine-code, nhưng nhiều người gọi/coi nó như binary-code, cũng dễ hiểu bởi vì machine-code là thứ CPU hiểu, machine-code được nạp vào CPU sẽ được diễn giải thành binary-code, còn diễn giải ra sao thì nó là câu truyện của 1 bầu trời kiến thức khác , vậy nên machine-code khác binary-code.
Kiến trúc máy tính(Computer architecture) là sự kết hợp của Instruction set architecture, micro architecture, logic design, implementation. Ở đây mình chỉ đề cập đến Instruction set architecture(ISA).
Mỗi loại CPU (CPU là 1 implementation của ISA) nó có 1 tập lệnh chứa các machine-code instructions, mỗi instructions đại diện cho 1 mẫu bit(là binary-code) được thiết kế sẵn cho những nhiệm vụ phần cứng nhất định. Khi CPU hoạt động thì sẽ trải qua rất nhiều các Instruction cycle để tìm nạp/diễn giải những machine instructions → binary-code → thực thi. Qúa trình này thực tế rất phức tạp vì còn liên quan đến các phần khác trong kiến trúc máy tính. CPU chỉ hiểu machine-code nên tất cả các ngôn ngữ khác như Assembly,C,C++,Switf,Java… đều phải dịch ra machine-code(machine instructions).
2. Vài nguyên nhân làm iOS nhanh hơn Android
Có rất nhiều nguyên nhân làm cho iOS nhanh hơn Android, nhưng trong nội dung bài này mình chỉ trình bày 3 nguyên nhân dựa trên những kiến thức được trình bày ở trên.
Nguyên nhân 1: Do thiết kế của ngôn ngữ
Ngoài lề một chút: Assembly language chạy nhanh hơn các Hight-level language(HHL) thuần biên dịch( compiled language) khoảng 50% dù cả 2 loại đều Assembler/Compiler ra machine-code. Lý do đơn giản vì những lập trình viên viết compiler cho HHL không thấy được những bài toán cụ thể mà chúng ta phải giải quyết, nên họ chỉ đưa ra những giải pháp tổng quát nhất để dịch được nhiều bài toán nhất, mà giải pháp tổng quát thì không thể tốt bằng giải pháp cụ thể cho từng bài toán đc, do đó compiler không thể dịch source-code của bạn ra machine-code tối ưu nhất được → compiler nó generate ra khá nhiều machine-code thừa thãi từ HHL → CPU phải chạy nhiều instructions hơn → lâu hơn. Còn coder assembly thì họ viết mã assembly cho từng bài toán cụ thể, nên assembler sẽ generate ra machine-code tối ưu hơn(ít hơn) → CPU chạy ít instructions hơn → nhanh hơn. Tóm lại những machine-code được dịch từ HHL sẽ kém hiệu quả hơn về mức độ sử dụng bộ nhớ, tốc độ và độ chính xác so với machine-code được dịch từ assembly language. Xem thêm tại đây.
Mã biên dịch(compiled code) nhanh hơn(1,5 - 5 lần) so với mã thông dịch( interpreted code). Mình không nhớ đã từng xem thống kê này ở đâu, nhưng về mặt kỹ thuật thì mã thông dịch cần chạy máy ảo để trình thông dịch sẽ dịch từ mã trung gian(byte-code) thành machine-code để CPU thực thi các machine instructions → thêm 1 bước dịch so với mã biên dịch → lâu hơn. Còn mã biên dịch thì nó đc dịch ra machine-code rồi, chỉ việc chạy(đẩy vào CPU) → nhanh hơn.
Ở trong context này thì iOS( với Objective-C/Swift) là compiled code, Android(với Java/Kotlin) là interpreted code -> iOS nhanh hơn Android. Nhưng như nội dung mình đã trình bày ở trên thì từ Android 5.0 trở đi, Android đã thay thế Dalvik(chỉ sử dụng JIT) bằng ART(sử dụng JIT kết hợp với AOT) từ đó hiệu suất được cải thiện hơn.
Nguyên nhân 2: Tự thiết kế phần cứng ( biết trước phần cứng sẽ chạy code)
Như ở trên lúc mình nói về app size của 2 HĐH này, mình có nói là mã Swift được biên dịch thẳng ra machine-code. iOS làm được điều này bởi vì họ biết phần cứng cụ thể họ dùng là gì rồi, không những thế mà Swift complier cũng được tối ưu việc generate machine-code dựa trên những phần cứng đó → Tạo ra tốc độ kinh ngạc của mã Swift khi chạy trên phần cứng do Apple thiết kế (cụ thể là CPU A series).
Nguyên nhân 3: Do thiết kế của hệ điều hành
Nội dung ở trên mình cũng có nhắc tới là Android sử dụng Garbage Collector(GC) để xử lý bộ nhớ, GC của Android sẽ xử lý bộ nhớ ở trong runtime (tức là cùng lúc thực thi) → ảnh hưởng phần nào đến quá trình thực thi app. Còn xử lý bộ nhớ bên iOS gọi là Automatic Reference Counting(ARC), ARC sẽ xử lý bộ nhớ trong khi biên dịch mã nguồn(tức là trước thực thi), xem rõ hơn tại đây và đây. Vậy nên ARC của iOS ít ảnh hưởng đến ứng dụng.
Vấn đề của Android ở đây là bộ nhớ HEAP sẽ không ngừng tăng lên cho đến khi nó được dọn dẹp bớt bởi GC( trong code bạn cũng có thể gọi System.gc()
để chủ động dọn dẹp, tuy nhiên không phải lúc nào nó cũng được chạy), mà chạy GC thì cũng lại cần thêm RAM. Do đó có thể có nhiều bộ nhớ được phân bổ hơn mức cần thiết. Điều này không tốt cho các thiết bị có bộ nhớ hạn chế. Và đó là lý do tại sao Android luôn đòi hỏi RAM cao hơn so với iOS.
Ở Android khi GC chạy, nó sẽ quét tất cả Objects ở trên HEAP để tìm ra Objects không còn được sử dụng(không còn reference từ STACK) và dọn dẹp vùng bộ nhớ của Objects đó. Đọc thôi đã thấy quá trình này nó rất là ... mệt → GC làm ảnh hưởng đến hiệu suất của Android. Còn ở phía iOS, khi biên dịch mã nguồn thì compiler bằng cách nào đó nó detect được Objects nào không còn được sử dụng và generate luôn ra đoạn machine-code để dọn dẹp vùng nhớ của objects đó → Điều này là không ảnh hưởng đến việc thực thi ứng dụng( vì không phải chạy 1 trình quét bộ nhớ như Android).
Tổng kết lại: Dù mình làm Android thì vẫn phải chấp nhận sự thật rằng Google có cố gắng tối ưu Android như thế nào đi chăng nữa thì Android sẽ không bao giờ mượt/nhanh như iOS được, vì những điểm yếu như trên. Và mình nghĩ Google cũng đã biết giới hạn tối ưu của Android rồi, nên họ gần đây mới giới thiệu hệ điều hành Fuchsia, tuy họ không nói rõ mục đích nhưng họ nói rằng HĐH này có thể chạy trên thiết bị di động, tức là không loại trừ khả năng nó có thể thay thế Android .
Mình mong bài viết này sẽ giúp các bạn nắm được khái quát quá trình từ khi ứng dụng được phát triển cho đến khi nó được thực thi. Hẹn gặp lại các bạn trong các bài viết sắp tới .
All rights reserved