Hiệu suất tốt hơn thông qua luồng

Việc sử dụng thành thạo các luồng trên Android có thể giúp bạn tăng hiệu suất ứng dụng. Trang này thảo luận một số khía cạnh về việc áp dụng luồng vào tương tác với giao diện người dùng hoặc luồng chính, luồng; mối quan hệ giữa vòng đời của ứng dụng và mức độ ưu tiên của luồng; và các phương thức nền tảng cung cấp để giúp quản lý độ phức tạp của luồng. Ở mỗi phần, trang này mô tả những sai lầm tiềm ẩn và chiến lược để có thể tránh được.

Luồng chính

Khi người dùng chạy ứng dụng, Android sẽ tạo một quy trình Linux mới cùng với luồng thực thi. Luồng chính này (còn được gọi là luồng giao diện người dùng) chịu trách nhiệm cho mọi hoạt động xảy ra trên màn hình. Việc hiểu được cách hoạt động của ứng dụng có thể giúp bạn thiết kế ứng dụng của mình sử dụng luồng chính nhằm mang lại hiệu suất tốt nhất có thể.

Nội bộ

Luồng chính có thiết kế rất đơn giản: Nhiệm vụ duy nhất của nó là nhận và thực thi các khối công việc từ hàng đợi tác vụ an toàn của luồng cho đến khi ứng dụng đóng. Khung này tạo ra một số khối công việc từ nhiều nơi khác nhau. Những vị trí này bao gồm các lệnh gọi lại liên kết với thông tin về vòng đời, các sự kiện của người dùng như sự kiện đầu vào hoặc sự kiện đến từ ứng dụng và quy trình khác. Ngoài ra, ứng dụng có thể tự xếp hàng các khối mà không cần sử dụng khung.

Hầu như bất kỳ khối mã nào mà ứng dụng của bạn thực thi đều được liên kết với một sự kiện lệnh gọi lại, chẳng hạn như sự kiện đầu vào, tăng cường bố cục hoặc vẽ. Khi có hành động kích hoạt một sự kiện, luồng nơi sự kiện đã xảy ra sẽ đẩy sự kiện đó ra khỏi chính nó và xếp vào hàng đợi thông báo của luồng chính. Luồng chính sau đó có thể phục vụ sự kiện.

Khi ảnh động hoặc bản cập nhật màn hình đang diễn ra, hệ thống sẽ cố gắng thực thi một khối tác vụ (có nhiệm vụ vẽ màn hình) sau mỗi 16 mili giây, để kết xuất một cách mượt mà với tốc độ 60 khung hình/giây. Để hệ thống có thể đạt được mục tiêu này, hệ phân cấp Giao diện người dùng/Chế độ xem phải cập nhật trên luồng chính. Tuy nhiên, khi hàng đợi thông báo của luồng chính chứa quá nhiều tác vụ hoặc tác vụ diễn ra trong thời gian quá lâu khiến luồng chính hoàn thành quá trình cập nhật chậm chạp, thì ứng dụng sẽ di chuyển tác vụ này sang một luồng thực thi. Nếu luồng chính không thể hoàn tất việc thực thi các khối tác vụ trong vòng 16 mili giây, thì người dùng có thể nhận thấy tình trạng đầu vào bị quá tải, chậm trễ hoặc thiếu khả năng thích ứng trên giao diện người dùng. Nếu luồng chính bị chặn trong khoảng 5 giây, hệ thống sẽ hiển thị hộp thoại Ứng dụng không phản hồi (ANR), cho phép người dùng đóng ứng dụng trực tiếp.

Di chuyển nhiều tác vụ hoặc các tác vụ diễn ra trong thời gian dài khỏi luồng chính, để chúng không làm ảnh hưởng đến độ mượt của hiển thị, đồng thời phản hồi nhanh đối với hoạt động đầu vào của người dùng, đây là lý do lớn nhất mà bạn phải áp dụng luồng trong ứng dụng của mình.

Luồng và tham chiếu đối tượng giao diện người dùng

Theo thiết kế, các đối tượng Chế độ xem Android không an toàn cho luồng. Một ứng dụng dự kiến sẽ tạo, sử dụng và huỷ các đối tượng giao diện người dùng, tất cả đều trên có trên luồng chính. Nếu bạn cố sửa đổi hoặc thậm chí là tham chiếu đối tượng giao diện người dùng trong một luồng khác luồng chính, thì kết quả có thể thuộc trường hợp ngoại lệ, lỗi ẩn, sự cố và hành vi không xác định khác.

Các vấn đề liên quan đến tệp tham chiếu được chia thành hai danh mục riêng biệt: tệp tham chiếu rõ ràng và tệp tham chiếu ngầm.

Tệp tham chiếu rõ ràng

Nhiều tác vụ trên các luồng không phải là luồng chính có mục tiêu cuối cùng là cập nhật đối tượng giao diện người dùng. Tuy nhiên, nếu một trong các luồng này truy cập vào một đối tượng trong hệ phân cấp khung hiển thị, sự bất ổn định của ứng dụng có thể dẫn đến: Nếu một luồng thực thi thay đổi các thuộc tính của đối tượng đó cùng lúc với bất kỳ luồng nào khác đang tham chiếu đến đối tượng, thì sẽ không xác định được kết quả.

Chẳng hạn hãy xem xét một ứng dụng chứa tham chiếu trực tiếp đến đối tượng giao diện người dùng trên một luồng thực thi. Đối tượng trên luồng thực thi có thể chứa tham chiếu đến View; nhưng trước khi tác vụ hoàn thành, View sẽ bị xoá khỏi hệ phân cấp khung hiển thị. Khi 2 hành động này xảy ra đồng thời, tham chiếu sẽ giữ đối tượng View trong bộ nhớ và đặt các thuộc tính trên đối tượng đó. Tuy nhiên, người dùng không bao giờ thấy được đối tượng này và ứng dụng sẽ xoá đối tượng sau khi hoàn tất tham chiếu đến đối tượng đó.

Trong một ví dụ khác, đối tượng View chứa các tham chiếu đến hoạt động sở hữu các đối tượng đó. Nếu hoạt động đó bị huỷ bỏ, nhưng vẫn còn một khối tác vụ theo luồng tham chiếu đến sự kiện đó (trực tiếp hoặc gián tiếp), thì trình thu gom rác sẽ không thu thập hoạt động cho đến khi khối tác vụ đó hoàn tất việc thực thi.

Tình huống này có thể gây ra sự cố trong các trường hợp tác vụ theo luồng có thể đang diễn ra cùng lần với một sự kiện trong vòng đời hoạt động, chẳng hạn như xoay màn hình. Hệ thống sẽ không thể thực hiện việc thu gom rác cho đến khi công việc đang diễn ra hoàn tất. Do đó, có thể có 2 đối tượng Activity trong bộ nhớ cho đến khi quá trình thu gom rác có thể diễn ra.

Trong những tình huống như thế này, bạn không nên đưa các tham chiếu rõ ràng vào đối tượng giao diện người dùng trong các tác vụ công việc theo luồng. Việc tránh các tham chiếu như vậy giúp bạn tránh các kiểu sự cố rò rỉ bộ nhớ này, đồng thời giúp loại bỏ tình trạng tranh chấp phân luồng.

Trong mọi trường hợp, ứng dụng của bạn chỉ nên cập nhật các đối tượng giao diện người dùng trên luồng chính. Điều này có nghĩa là bạn nên tạo một chính sách thương lượng cho phép nhiều luồng giao tiếp trở lại với luồng chính, thực hiện tác vụ trên cùng hoặc mảnh hoạt động trên cùng để cập nhật đối tượng giao diện người dùng thực tế.

Tệp tham chiếu ngầm

Bạn có thể thấy một lỗi thiết kế mã phổ biến với các đối tượng luồng trong đoạn mã dưới đây:

Kotlin

class MainActivity : Activity() {
    // ...
    inner class MyAsyncTask : AsyncTask<Unit, Unit, String>() {
        override fun doInBackground(vararg params: Unit): String {...}
        override fun onPostExecute(result: String) {...}
    }
}

Java

public class MainActivity extends Activity {
  // ...
  public class MyAsyncTask extends AsyncTask<Void, Void, String>   {
    @Override protected String doInBackground(Void... params) {...}
    @Override protected void onPostExecute(String result) {...}
  }
}

Điểm thiếu sót trong đoạn mã này là mã khai báo đối tượng luồng MyAsyncTask dưới dạng một lớp không tĩnh bên trong một số hoạt động (hoặc lớp bên trong Kotlin). Mã khai báo này tạo một tham chiếu ngầm cho thực thể Activity đi kèm. Do đó, đối tượng này sẽ tham chiếu đến hoạt động cho đến khi tác vụ theo luồng hoàn tất, gây chậm trễ cho việc huỷ bỏ hoạt động được tham chiếu. Sự chậm trễ này lại còn gây ra áp lực lớn hơn cho bộ nhớ.

Một giải pháp trực tiếp cho vấn đề này là xác định các thực thể lớp bị quá tải dưới dạng lớp tĩnh, hoặc trong các tệp riêng của thực thể để có thể loại bỏ các tham chiếu ngầm.

Ngoài ra còn một giải pháp khác nữa là luôn huỷ và dọn dẹp các tác vụ nền trong phương thức gọi lại của vòng đời Activity thích hợp, chẳng hạn như onDestroy. Tuy nhiên, phương pháp này có thể tẻ nhạt và dễ xảy ra lỗi. Nguyên tắc chung là bạn không nên đặt trực tiếp logic phức tạp, không mang tính giao diện người dùng vào các hoạt động. Ngoài ra, AsyncTask hiện không còn được dùng nữa và bạn không nên sử dụng trong mã mới. Vui lòng xem phần Tạo luồng trên Android để biết thêm thông tin chi tiết về các thuộc tính gốc đồng thời có sẵn cho bạn.

Luồng và vòng đời hoạt động của ứng dụng

Vòng đời của ứng dụng có thể ảnh hưởng đến cách phân luồng hoạt động trong ứng dụng của bạn. Bạn có thể cần phải quyết định nên giữ hay bỏ một luồng sau khi một hoạt động bị huỷ. Bạn cũng nên nắm được mối quan hệ giữa mức độ ưu tiên của luồng và liệu một hoạt động có đang chạy ở nền trước hay nền sau.

Duy trì luồng

Các luồng được duy trì trong suốt vòng đời của hoạt động tạo ra chúng. Các luồng vẫn sẽ tiếp tục thực thi, không bị gián đoạn, bất kể các hoạt động có được tạo hay bị huỷ bỏ, mặc dù các luồng này sẽ bị chấm dứt cùng với quy trình ứng dụng khi không còn thành phần ứng dụng nào hoạt động nữa. Trong một số trường hợp, sự duy trì này là cần thiết.

Hãy xem xét trường hợp một hoạt động tạo ra nhóm các khối công việc theo luồng, sau đó bị huỷ bỏ trước khi một luồng worker có thể thực thi các khối đó. Ứng dụng nên làm gì với các khối đang chạy?

Nếu các khối đó cập nhật giao diện người dùng không còn tồn tại, thì không có lý do gì để tiếp tục nhiệm vụ. Ví dụ nếu nhiệm vụ ở đây là tải thông tin người dùng từ cơ sở dữ liệu, sau đó cập nhật chế độ xem, thì luồng không còn cần thiết nữa.

Ngược lại, các gói tác vụ có thể có một số lợi ích không hoàn toàn liên quan đến giao diện người dùng. Trong trường hợp này, bạn nên duy trì luồng này. Chẳng hạn các gói có thể đang chờ tải hình ảnh xuống, lưu vào bộ nhớ đệm và cập nhật đối tượng View được liên kết. Mặc dù đối tượng này không còn tồn tại, nhưng hành động tải xuống và lưu vào bộ nhớ đệm hình ảnh vẫn có thể hữu ích, trong trường hợp người dùng quay lại hoạt động đã huỷ bỏ.

Việc quản lý phản hồi trong vòng đời theo cách thủ công cho tất cả các đối tượng tạo luồng có thể trở nên cực kỳ phức tạp. Nếu bạn không quản lý đúng cách, ứng dụng có thể gặp phải các vấn đề về hiệu suất và tranh giành bộ nhớ. Việc kết hợp ViewModel với LiveData cho phép bạn tải dữ liệu và nhận thông báo khi dữ liệu thay đổi mà không phải lo lắng về vòng đời. Các đối tượng ViewModel là một giải pháp cho vấn đề này. ViewModel được duy trì được duy trì qua các lần thay đổi về cấu hình, cách này giúp bạn dễ dàng duy trì dữ liệu chế độ xem của mình. Để biết thêm thông tin chi tiết về ViewModel, vui lòng xem hướng dẫn về ViewModel, và để tìm hiểu thêm về LiveData, vui lòng xem hướng dẫn về LiveData. Nếu bạn cũng muốn biết thêm thông tin về cấu trúc ứng dụng, vui lòng đọc Hướng dẫn về cấu trúc ứng dụng.

Mức độ ưu tiên của luồng

Như đã mô tả trong Quy trình và Vòng đời của ứng dụng, mức độ ưu tiên mà các luồng của ứng dụng nhận được sẽ phụ thuộc một phần vào vị trí của ứng dụng trong vòng đời của nó. Khi tạo và quản lý các luồng trong ứng dụng, bạn cần đặt mức độ ưu tiên để các luồng phù hợp nhận được mức độ ưu tiên phù hợp vào đúng thời điểm. Nếu được đặt quá cao, luồng của bạn có thể làm gián đoạn luồng giao diện người dùng và RendererThread, khiến ứng dụng của bạn bị bỏ khung hình. Nếu đặt quá thấp, bạn có thể làm cho các tác vụ không đồng bộ của mình (chẳng hạn như tải hình ảnh) chậm hơn mức cần thiết.

Mỗi khi tạo một luồng, bạn nên gọi setThreadPriority(). Trình lập lịch biểu của hệ thống ưu tiên các luồng có mức độ ưu tiên cao, cân bằng những ưu tiên đó với nhu cầu cuối cùng là hoàn tất mọi tác vụ. Nhìn chung, các luồng trong nhóm nền trước nhận được khoảng 95% tổng thời gian thực thi từ thiết bị, trong khi nhóm nền nhận được khoảng 5%.

Hệ thống cũng chỉ định giá trị ưu tiên riêng cho từng luồng bằng cách sử dụng lớp Process.

Theo mặc định, hệ thống sẽ đặt cùng mức độ ưu tiên cho một luồng và các thành viên trong nhóm như nhau dưới dạng luồng sinh sản. Tuy nhiên, ứng dụng của bạn có thể điều chỉnh mức độ ưu tiên của luồng rõ ràng bằng cách sử dụng setThreadPriority().

Lớp Process giúp giảm độ phức tạp trong việc chỉ định giá trị ưu tiên bằng cách cung cấp một tập hợp các hằng số mà ứng dụng dùng để đặt mức độ ưu tiên cho luồng. Ví dụ: THREAD_PRIORITY_DEFAULT đại diện cho giá trị mặc định của một luồng. Ứng dụng của bạn nên đặt mức độ ưu tiên của luồng thành THREAD_PRIORITY_BACKGROUND đối với các luồng đang thực thi tác vụ ít khẩn cấp hơn.

Ứng dụng của bạn có thể sử dụng các hằng số THREAD_PRIORITY_LESS_FAVORABLETHREAD_PRIORITY_MORE_FAVORABLE dưới dạng bộ tăng để đặt mức độ ưu tiên tương đối. Để biết danh sách mức độ ưu tiên của luồng, hãy xem hằng số THREAD_PRIORITY trong lớp Process.

Để biết thêm thông tin về cách quản lý luồng, vui lòng xem tài liệu tham khảo về các lớp ThreadProcess.

Các lớp trợ giúp để phân luồng

Nếu bạn thuộc nhóm các nhà phát triển sử dụng Kotlin làm ngôn ngữ chính, bạn nên dùng coroutine. Coroutine cung cấp một số lợi ích, bao gồm việc viết mã không đồng bộ mà không cần lệnh gọi lại cũng như tính năng đồng thời có cấu trúc để xác định phạm vi, huỷ bỏ và xử lý lỗi.

Khung này cũng cung cấp các lớp Java và nguyên thuỷ tương tự để hỗ trợ hoạt động tạo luồng, chẳng hạn như các lớp Thread, RunnableExecutors, cũng như các lớp bổ sung HandlerThread. Để biết thêm thông tin chi tiết, vui lòng tham khảo bài viết Tạo luồng trên Android.

Lớp HandlerThread

Luồng xử lý là một luồng chạy hiệu quả trong thời gian dài, nhận các tác vụ từ hàng đợi và hoạt động trên luồng đó.

Hãy xem xét một thử thách phổ biến khi lấy khung hình xem trước từ đối tượng Camera. Khi đăng ký các khung hình xem trước của Máy ảnh, bạn sẽ nhận được các khung hình này trong lệnh gọi lại onPreviewFrame(), được gọi trên luồng sự kiện nơi nó được gọi. Nếu lệnh gọi lại này được gọi trên luồng giao diện người dùng, thì tác vụ xử lý các mảng pixel khổng lồ sẽ gây cản trở cho việc kết xuất và xử lý sự kiện.

Trong ví dụ này, khi ứng dụng của bạn uỷ quyền lệnh Camera.open() cho một khối công việc trên luồng của trình xử lý, lệnh gọi lại onPreviewFrame() được liên kết sẽ chuyển đến luồng của trình xử lý, thay vì luồng giao diện người dùng. Vì vậy, nếu bạn định thực hiện một tác vụ tốn nhiều thời gian trên pixel, thì đây có thể là giải pháp tốt hơn cho bạn.

Khi ứng dụng của bạn tạo luồng bằng HandlerThread, đừng quên đặt mức độ ưu tiên của luồng dựa trên tác vụ mà ứng dụng đó đang thực hiện. Vui lòng nhớ CPU chỉ có thể xử lý một số lượng luồng nhỏ cùng lúc. Việc đặt mức độ ưu tiên sẽ giúp hệ thống biết cách phù hợp để lên lịch tác vụ này khi tất cả các luồng khác đang tranh giành sự chú ý.

Lớp ThreadPoolExecutor

Một số kiểu tác vụ nhất định có thể được giảm xuống thành các tác vụ được phân phối song song. Một ví dụ về kiểu tác vụ này là tính toán bộ lọc cho mỗi khối 8x8 của một hình ảnh 8 megapixel. Với khối lượng lớn các gói nhiệm vụ của tác vụ này thì HandlerThread không phải là lớp thích hợp để sử dụng.

ThreadPoolExecutor là một lớp hỗ trợ để giúp quy trình này dễ dàng hơn. Lớp này sẽ quản lý việc tạo một nhóm các luồng, đặt mức độ ưu tiên cho các luồng đó và quản lý cách phân phối công việc giữa các luồng này. Khi khối lượng công việc tăng hoặc giảm, lớp này sẽ xoay vòng hoặc huỷ bỏ nhiều luồng hơn để điều chỉnh khối lượng công việc.

Lớp này cũng giúp ứng dụng của bạn tạo số lượng luồng tối ưu. Khi tạo một đối tượng ThreadPoolExecutor, ứng dụng sẽ đặt số lượng luồng tối thiểu và tối đa. Khi khối lượng công việc được cung cấp cho ThreadPoolExecutor tăng lên, lớp này sẽ xem xét số lượng luồng tối thiểu và tối đa đã khởi tạo và khối lượng công việc đang chờ xử lý. Dựa vào những yếu tố này, ThreadPoolExecutor sẽ quyết định số lượng luồng sẽ tồn tại vào thời điểm bất kỳ.

Bạn nên tạo bao nhiêu luồng?

Mặc dù ở cấp phần mềm, mã của bạn đã có khả năng tạo hàng trăm luồng, nhưng việc này có thể tạo ra các vấn đề về hiệu suất. Ứng dụng của bạn chia sẻ với các tài nguyên CPU hạn chế với các dịch vụ nền, trình kết xuất, công cụ âm thanh, kết nối mạng và nhiều tài nguyên khác. CPU thực sự chỉ có thể xử lý một số lượng nhỏ các luồng song song; mọi vấn đề đã nêu ở trên đều có thể gặp phải các sự cố về mức độ ưu tiên và lập lịch. Do đó, bạn chỉ nên tạo nhiều luồng tuỳ theo nhu cầu của khối lượng công việc.

Trên thực tế, có một số biến chịu trách nhiệm cho những nội dung này, nhưng việc chọn một giá trị (như 4 chẳng hạn, cho người mới bắt đầu) và thử nghiệm giá trị đó với Systrace là một chiến lược hay như bất kỳ giá trị nào khác. Bạn có thể sử dụng phương pháp thử và sai để tìm ra số lượng luồng tối thiểu bạn có thể sử dụng mà không gặp sự cố.

Một yếu tố khác cần xem xét khi quyết định số lượng luồng là các luồng đều không miễn phí: vì chúng chiếm bộ nhớ. Mỗi luồng có chi phí bộ nhớ tối thiểu là 64k. Nó nhanh chóng được thêm vào nhiều ứng dụng được cài đặt trên một thiết bị, đặc biệt là trong các tình huống mà ngăn xếp cuộc gọi tăng lên đáng kể.

Nhiều quy trình hệ thống và thư viện bên thứ ba thường xoay vòng tạo các luồng nhóm riêng. Nếu ứng dụng của bạn có thể sử dụng lại một nhóm luồng hiện có, thì việc tái sử dụng này có thể giúp tăng hiệu suất bằng cách giảm mức độ tranh chấp về bộ nhớ và tài nguyên xử lý.