0

Quản lý bộ nhớ tự động trong Unity

Khi một đối tượng, chuỗi hoặc mảng được tạo ra, bộ nhớ cần thiết để lưu trữ nó được phân bổ từ một "pool" trung tâm được gọi là heap. Khi item đã không còn sử dụng, bộ nhớ của nó có thể được reclaim và sử dụng cho cái gì khác. Trong quá khứ, thông thường các lập trình viên cấp phat và giải phóng các khối của bộ nhớ heap một cách rõ ràng với các cuộc gọi chức năng thích hợp. Ngày nay, runtime system chạy như động cơ Mono Unity của quản lý bộ nhớ cho bạn một cách tự động. Quản lý bộ nhớ tự động đòi hỏi code ít hơn và làm giảm đáng kể khả năng rò rỉ bộ nhớ (các tình huống mà bộ nhớ được cấp nhưng không bao giờ giải phóng).

  1. Các kiểu giá trị và reference

    Khi một hàm được gọi, các giá trị của các thông số được sao chép vào một khu vực của bộ nhớ dành riêng cho cuộc gọi cụ thể. kiểu dữ liệu mà chỉ chiếm một vài byte có thể được sao chép rất nhanh chóng và dễ dàng. Tuy nhiên, nó được phổ biến cho các đối tượng, các chuỗi và mảng phải lớn hơn nhiều và nó sẽ rất hiệu quả với những loại dữ liệu đã được sao chép một cách thường xuyên. May mắn thay, điều này là không cần thiết; không gian lưu trữ thực tế cho một item lớn được phân bổ từ heap và một "con trỏ" giá trị nhỏ được sử dụng để ghi nhớ vị trí của nó. Từ đó về sau, chỉ có con trỏ cần được sao chép trong tham số. Miễn là các runtime system chạy có thể xác định vị trí các item được xác định bởi con trỏ, một copy duy nhất của các dữ liệu có thể được sử dụng thường xuyên khi cần thiết.

    Các type được lưu trữ trực tiếp và sao chép trong parameter passing này được gọi là kiểu giá trị. Chúng bao gồm các số nguyên, số thực, các phép toán và các loại cấu trúc Unity (ví dụ như, Color và Vector3). Các loại được cấp phát trên heap và sau đó truy cập thông qua một con trỏ được gọi là kiểu tham chiếu, vì giá trị được lưu trữ trong biến chỉ "refer" tới các dữ liệu thực tế. Ví dụ về các loại reference bao gồm các đối tượng, các chuỗi và mảng.

  2. Cấp phat và thu dọn

    Người quản lý bộ nhớ theo dõi những khu vực trong heap mà nó biết là không sử dụng. Khi một block memory mới được yêu cầu ( khi một đối tượng được khởi tạo), người quản lý sẽ chọn một khu vực không sử dụng từ đó phân bổ các block và sau đó loại bỏ các cấp phát bộ nhớ từ không gian chưa sử dụng dã biết. Các yêu cầu sau đó được xử lý theo cùng một cách cho đến khi không có khu vực miễn phí đủ lớn nào để phân bổ các block với kích thước yêu cầu. Sẽ là rất khó xảy ra với trường hợp tất cả các cấp phát bộ nhớ từ block vẫn được sử dụng đồng thời. Một item refrence trên heap chỉ có thể được truy cập miễn là vẫn còn đang giá trị reference mà có thể xác định vị trí của nó. Nếu tất cả các reference đến một heap bộ nhớ không còn nữa (tức là, các biến reference đã được bố trí hoặc họ là biến local mà bây giờ ra khỏi scope) thì bộ nhớ nó chiếm một cách an toàn có thể được phân bổ lại.

    Để xác định các heap block không còn sử dụng, tìm kiếm quản lý bộ nhớ thông qua tất cả các biến reference hiện đang hoạt động và đánh dấu các block họ gọi là "live". Vào cuối của tìm kiếm, bất kỳ không gian giữa các live block được coi là sản phẩm nào bởi người quản lý bộ nhớ và có thể được sử dụng để phân bổ tiếp theo. Vì những lý do hiển nhiên, quá trình định vị và giải phóng bộ nhớ không sử dụng được gọi là thu gom rác thải (hoặc GC cho ngắn).

  3. Tối ưu hóa

    Thu gom rác thải là tự động và vô hình cho các lập trình viên nhưng quá trình thu thập thực sự đòi hỏi thời gian đáng kể của CPU. Khi được sử dụng một cách chính xác, quản lý bộ nhớ tự động sẽ thường bằng hoặc "beat" phân bổ sử dụng cho hiệu suất tổng thể. Tuy nhiên, điều quan trọng là dành cho các lập trình viên để tránh những sai lầm đó sẽ kích hoạt các collector thường xuyên hơn cần thiết và tạm dừng trong khi thực thi.

    Có một số thuật toán nổi tiếng mà có thể là cơn ác mộng GC mặc dù họ có vẻ vô tội ngay từ cái nhìn đầu tiên. Lặp đi lặp lại nối chuỗi là một ví dụ điển hình:

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void ConcatExample(int[] intArray) {
        string line = intArray[0].ToString();

        for (i = 1; i < intArray.Length; i++) {
            line += ", " + intArray[i].ToString();
        }

        return line;
    }
}

//JS script example
function ConcatExample(intArray: int[]) {
    var line = intArray[0].ToString();

    for (i = 1; i < intArray.Length; i++) {
        line += ", " + intArray[i].ToString();
    }

    return line;
}

Các chi tiết quan trọng ở đây là các piece mới không được thêm vào chuỗi tại chỗ, từng cái một. Điều gì thực sự xảy ra là mỗi time around của vòng lặp, các nội dung trước đó của biến trở thành chết - một chuỗi hoàn toàn mới được phân bổ để chứa các piece ban đầu cộng với một phần mới ở cuối. Kể từ khi chuỗi được lâu hơn với giá trị ngày càng cao của i, số lượng không gian heap được tiêu thụ cũng tăng và vì vậy nó rất dễ sử dụng lên hàng trăm byte của không gian heap miễn phí mỗi thời gian chức năng này được gọi. Nếu bạn cần phải nối nhiều chuỗi lại với nhau sau đó là một lựa chọn tốt hơn nhiều là lớp System.Text.StringBuilder thư viện của Mono.

Tuy nhiên, nối thậm chí lặp đi lặp lại sẽ không gây ra quá nhiều rắc rối, trừ khi nó được gọi thường xuyên, và trong Unity mà thường imply cập nhật frame. Giống như:

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    public GUIText scoreBoard;
    public int score;

    void Update() {
        string scoreText = "Score: " + score.ToString();
        scoreBoard.text = scoreText;
    }
}

//JS script example
var scoreBoard: GUIText;
var score: int;

function Update() {
    var scoreText: String = "Score: " + score.ToString();
    scoreBoard.text = scoreText;
}

... Sẽ phân bổ chuỗi mới mỗi lần Update được gọi và liên tục tạo ra một dòng chảy nhỏ giọt của rác mới. Hầu hết trong số đó có thể được lưu bằng cách update các text chỉ khi điểm số thay đổi:

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    public GUIText scoreBoard;
    public string scoreText;
    public int score;
    public int oldScore;

    void Update() {
        if (score != oldScore) {
            scoreText = "Score: " + score.ToString();
            scoreBoard.text = scoreText;
            oldScore = score;
        }
    }
}

//JS script example
var scoreBoard: GUIText;
var scoreText: String;
var score: int;
var oldScore: int;

function Update() {
    if (score != oldScore) {
        scoreText = "Score: " + score.ToString();
        scoreBoard.text = scoreText;
        oldScore = score;
    }
}

Một vấn đề tiềm tàng xảy ra khi một hàm trả về một giá trị mảng:

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    float[] RandomList(int numElements) {
        var result = new float[numElements];

        for (int i = 0; i < numElements; i++) {
            result[i] = Random.value;
        }

        return result;
    }
}

//JS script example
function RandomList(numElements: int) {
    var result = new float[numElements];

    for (i = 0; i < numElements; i++) {
        result[i] = Random.value;
    }

    return result;
}

Đây là loại chức năng là rất đẹp và tiện lợi khi tạo một mảng mới đầy giá trị. Tuy nhiên, nếu nó được gọi là nhiều lần sau đó bộ nhớ mới sẽ được phân bổ mỗi lần. Kể từ mảng có thể rất lớn, không gian heap miễn phí có thể được sử dụng hết nhanh chóng, dẫn đến các GC thường xuyên. Một cách để tránh vấn đề này là sử dụng một mảng là một kiểu tham chiếu. Một mảng thông qua vào một chức năng như một tham số có thể được thay đổi trong chức năng và kết quả sẽ vẫn còn sau khi trở về chức năng. Một chức năng như trên thường có thể được thay thế bằng cách sau:

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void RandomList(float[] arrayToFill) {
        for (int i = 0; i < arrayToFill.Length; i++) {
            arrayToFill[i] = Random.value;
        }
    }
}

//JS script example
function RandomList(arrayToFill: float[]) {
    for (i = 0; i < arrayToFill.Length; i++) {
        arrayToFill[i] = Random.value;
    }
}

Điều này chỉ đơn giản là thay thế các nội dung hiện có của mảng với giá trị mới. Mặc dù điều này đòi hỏi việc phân bổ ban đầu của mảng được thực hiện trong code, các chức năng sẽ không tạo ra bất kỳ rác mới khi nó được gọi.

  1. Request một Collection

    Như đã đề cập ở trên, sẽ là tốt nhất để tránh phân bổ càng xa càng tốt. Tuy nhiên, sẽ không thể loại bỏ hoàn toàn, có hai chiến lược chính, bạn có thể sử dụng để giảm thiểu tối đa sự xâm nhập của họ vào trò chơi:

4.1 Small heap với GC nhanh và thường hơn

Chiến lược này thường là tốt nhất cho các game có thời gian chơi dài nơi một frame mịn là mối quan tâm chính. Một trò chơi như thế này thường sẽ phân bổ các heap nhỏ thường xuyên nhưng chỉ có thể sử dụng một thời gian ngắn. Các kích thước heap điển hình khi sử dụng chiến lược này trên iOS là khoảng 200KB và thu gom rác thải sẽ mất khoảng 5ms trên iPhone 3G. Nếu heap tăng lên đến 1MB, collection sẽ mất khoảng 7ms. Do đó, nó có thể là một lợi thế đôi khi yêu cầu thu gom rác thải tại một khoảng thời gian khung hình thường xuyên. Điều này thường sẽ làm cho các collector thường xuyên xảy ra nhiều hơn thực sự cần thiết nhưng sẽ được xử lý một cách nhanh chóng và có hiệu lực tối thiểu trên gameplay:

if (Time.frameCount % 30 == 0)
{
   System.GC.Collect();
}

Tuy nhiên, bạn nên sử dụng kỹ thuật này một cách thận trọng và kiểm tra số liệu thống kê để chắc chắn rằng nó thực sự giảm thời gian thu thập cho trò chơi của bạn.

4.2 Large heap chậm nhưng với GC ít thường xuyên hơn

Chiến lược này làm việc tốt nhất cho các trò chơi, nơi phân bổ là tương đối thường xuyên và có thể được xử lý trong tạm dừng trong gameplay. Nó rất hữu ích cho các heap là càng lớn càng tốt mà không bị quá lớn như để có được ứng dụng của bạn bị giết bởi các hệ điều hành do bộ nhớ hệ thống thấp. Tuy nhiên, thời gian chạy Mono tránh mở rộng các heap tự động nếu có thể. Bạn có thể mở rộng các heap bằng tay bằng cách preallocating một số không gian giữ chỗ trong khi khởi động (ví dụ, bạn tạo một đối tượng "useless" được phân bổ hoàn toàn là ảnh hưởng của nó vào người quản lý bộ nhớ):

//C# script example
using UnityEngine;
using System.Collections;

public class ExampleScript : MonoBehaviour {
    void Start() {
        var tmp = new System.Object[1024];

        // make allocations in smaller blocks to avoid them to be treated in a special way, which is designed for large blocks
        for (int i = 0; i < 1024; i++)
            tmp[i] = new byte[1024];

        // release reference
        tmp = null;
    }
}

//JS script example
function Start() {
    var tmp = new System.Object[1024];

    // make allocations in smaller blocks to avoid them to be treated in a special way, which is designed for large blocks
        for (var i : int = 0; i < 1024; i++)
        tmp[i] = new byte[1024];

    // release reference
        tmp = null;
}

Một heap đủ lớn nên không được hoàn toàn lấp đầy giữa những khoảng tạm dừng trong gameplay mà sẽ chứa một collection. Khi tạm dừng như vậy xảy ra, bạn có thể yêu cầu một collection một cách rõ ràng:

System.GC.Collect();

Và một lần nữa, bạn nên sử dụng kỹ thuật này một cách thận trọng và kiểm tra số liệu thống kê để chắc chắn rằng nó thực sự giảm thời gian thu thập cho trò chơi của bạn.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí