Phiên bản ngắn: Luôn sử dụng calloc()
thay vì malloc()+memset()
. Trong hầu hết các trường hợp, chúng sẽ giống nhau. Trong một số trường hợp, calloc()
sẽ làm ít việc hơn vì nó có thể bỏ qua memset()
hoàn toàn. Trong các trường hợp khác, calloc()
thậm chí có thể gian lận và không phân bổ bất kỳ bộ nhớ! Tuy nhiên, malloc()+memset()
sẽ luôn luôn làm toàn bộ công việc.
Hiểu điều này đòi hỏi một chuyến tham quan ngắn của hệ thống bộ nhớ.
Tham quan nhanh bộ nhớ
Có bốn phần chính ở đây: chương trình của bạn, thư viện chuẩn, kernel và bảng trang. Bạn đã biết chương trình của bạn, vì vậy ...
Các bộ cấp phát bộ nhớ thích malloc()
và calloc()
chủ yếu ở đó để lấy các phân bổ nhỏ (mọi thứ từ 1 byte đến 100 KB) và nhóm chúng vào các nhóm bộ nhớ lớn hơn. Ví dụ: nếu bạn phân bổ 16 byte, malloc()
trước tiên sẽ cố gắng lấy 16 byte từ một trong các nhóm của nó, sau đó yêu cầu thêm bộ nhớ từ kernel khi pool cạn. Tuy nhiên, vì chương trình bạn hỏi về việc phân bổ một lượng lớn bộ nhớ cùng một lúc malloc()
và calloc()
sẽ chỉ yêu cầu bộ nhớ đó trực tiếp từ kernel. Ngưỡng cho hành vi này tùy thuộc vào hệ thống của bạn, nhưng tôi đã thấy 1 MiB được sử dụng làm ngưỡng.
Hạt nhân chịu trách nhiệm phân bổ RAM thực tế cho từng quy trình và đảm bảo rằng các quy trình không can thiệp vào bộ nhớ của các quy trình khác. Điều này được gọi là bảo vệ bộ nhớ, nó đã trở nên phổ biến từ những năm 1990 và đó là lý do tại sao một chương trình có thể bị sập mà không làm sập toàn bộ hệ thống. Vì vậy, khi một chương trình cần thêm bộ nhớ, nó không thể lấy bộ nhớ mà thay vào đó, nó yêu cầu bộ nhớ từ kernel bằng cách gọi hệ thống như mmap()
hoặc sbrk()
. Nhân sẽ cung cấp RAM cho mỗi quy trình bằng cách sửa đổi bảng trang.
Bảng trang ánh xạ các địa chỉ bộ nhớ vào RAM vật lý thực tế. Địa chỉ của quy trình của bạn, 0x00000000 đến 0xFFFFFFFF trên hệ thống 32 bit, không phải là bộ nhớ thực mà thay vào đó là các địa chỉ trong bộ nhớ ảo. Bộ xử lý chia các địa chỉ này thành 4 trang KiB và mỗi trang có thể được gán cho một phần RAM vật lý khác nhau bằng cách sửa đổi bảng trang. Chỉ hạt nhân được phép sửa đổi bảng trang.
Làm thế nào nó không hoạt động
Đây là cách phân bổ 256 MiB không hoạt động:
Quá trình của bạn gọi calloc()
và yêu cầu 256 MiB.
Thư viện tiêu chuẩn gọi mmap()
và yêu cầu 256 MiB.
Nhân tìm thấy 256 MiB RAM không sử dụng và cung cấp cho quy trình của bạn bằng cách sửa đổi bảng trang.
Thư viện tiêu chuẩn thay đổi RAM với memset()
và trả về calloc()
.
Quá trình của bạn cuối cùng thoát ra và hạt nhân lấy lại RAM để nó có thể được sử dụng bởi một quy trình khác.
Làm thế nào nó thực sự hoạt động
Quá trình trên sẽ hoạt động, nhưng nó không xảy ra theo cách này. Có ba sự khác biệt chính.
Khi quy trình của bạn nhận được bộ nhớ mới từ kernel, bộ nhớ đó có thể đã được sử dụng bởi một số quy trình khác trước đó. Đây là một rủi ro bảo mật. Điều gì nếu bộ nhớ đó có mật khẩu, khóa mã hóa hoặc công thức nấu ăn salsa bí mật? Để giữ cho dữ liệu nhạy cảm không bị rò rỉ, kernel luôn xóa bộ nhớ trước khi đưa nó vào một quy trình. Chúng tôi cũng có thể xóa bộ nhớ bằng cách xóa sạch bộ nhớ và nếu bộ nhớ mới bằng 0, chúng tôi cũng có thể mmap()
đảm bảo bộ nhớ , vì vậy đảm bảo rằng bộ nhớ mới mà nó trả về luôn luôn bằng không.
Có rất nhiều chương trình ngoài đó phân bổ bộ nhớ nhưng không sử dụng bộ nhớ ngay lập tức. Một số lần bộ nhớ được phân bổ nhưng không bao giờ được sử dụng. Nhân biết điều này và lười biếng. Khi bạn phân bổ bộ nhớ mới, kernel hoàn toàn không chạm vào bảng trang và không cung cấp bất kỳ RAM nào cho quy trình của bạn. Thay vào đó, nó tìm thấy một số không gian địa chỉ trong quy trình của bạn, ghi lại những gì được cho là sẽ đến đó và hứa rằng nó sẽ đặt RAM ở đó nếu chương trình của bạn thực sự sử dụng nó. Khi chương trình của bạn cố đọc hoặc ghi từ các địa chỉ đó, bộ xử lý sẽ gây ra lỗi trang và các bước kernel trong việc gán RAM cho các địa chỉ đó và tiếp tục chương trình của bạn. Nếu bạn không bao giờ sử dụng bộ nhớ, lỗi trang sẽ không bao giờ xảy ra và chương trình của bạn không bao giờ thực sự có RAM.
Một số quy trình phân bổ bộ nhớ và sau đó đọc từ nó mà không sửa đổi nó. Điều này có nghĩa là rất nhiều trang trong bộ nhớ trong các quy trình khác nhau có thể được lấp đầy bằng các số 0 nguyên sơ được trả về từ đó mmap()
. Vì các trang này đều giống nhau, nên kernel làm cho tất cả các địa chỉ ảo này trỏ đến một trang bộ nhớ 4 KiB được chia sẻ chứa đầy số không. Nếu bạn cố ghi vào bộ nhớ đó, bộ xử lý sẽ gây ra lỗi trang khác và nhân bước vào để cung cấp cho bạn một trang số 0 mới không được chia sẻ với bất kỳ chương trình nào khác.
Quá trình cuối cùng trông giống như thế này:
Quá trình của bạn gọi calloc()
và yêu cầu 256 MiB.
Thư viện tiêu chuẩn gọi mmap()
và yêu cầu 256 MiB.
Hạt nhân tìm thấy 256 MiB của không gian địa chỉ không được sử dụng , ghi chú về không gian địa chỉ đó hiện được sử dụng và trả về.
Thư viện chuẩn biết rằng kết quả mmap()
luôn chứa đầy số 0 (hoặc sẽ là một khi nó thực sự có RAM), vì vậy nó không chạm vào bộ nhớ, do đó không có lỗi trang và RAM không bao giờ được đưa vào quy trình của bạn .
Quá trình của bạn cuối cùng đã thoát và kernel không cần lấy lại RAM vì nó không bao giờ được phân bổ ở vị trí đầu tiên.
Nếu bạn sử dụng memset()
để zero trang, memset()
sẽ kích hoạt lỗi trang, khiến RAM được phân bổ, và sau đó bằng 0 ngay cả khi nó đã được điền bằng 0. Đây là một lượng lớn công việc làm thêm, và giải thích tại sao calloc()
nhanh hơn malloc()
và memset()
. Nếu cuối cùng vẫn sử dụng bộ nhớ, calloc()
vẫn nhanh hơn malloc()
và memset()
nhưng sự khác biệt không quá vô lý.
Điều này không phải lúc nào cũng hoạt động
Không phải tất cả các hệ thống đều có bộ nhớ ảo phân trang, vì vậy không phải tất cả các hệ thống đều có thể sử dụng các tối ưu hóa này. Điều này áp dụng cho các bộ xử lý rất cũ như 80286 cũng như các bộ xử lý nhúng chỉ quá nhỏ cho một đơn vị quản lý bộ nhớ tinh vi.
Điều này cũng sẽ không luôn luôn làm việc với phân bổ nhỏ hơn. Với phân bổ nhỏ hơn, calloc()
lấy bộ nhớ từ nhóm chung thay vì truy cập trực tiếp vào kernel. Nói chung, nhóm chia sẻ có thể có dữ liệu rác được lưu trữ trong đó từ bộ nhớ cũ đã được sử dụng và giải phóng free()
, do đó, calloc()
có thể lấy bộ nhớ đó và gọi memset()
để xóa nó. Các triển khai chung sẽ theo dõi phần nào của nhóm chia sẻ nguyên sơ và vẫn chứa đầy số không, nhưng không phải tất cả các triển khai đều thực hiện việc này.
Xua tan một số câu trả lời sai
Tùy thuộc vào hệ điều hành, kernel có thể có hoặc không bộ nhớ trong thời gian rảnh, trong trường hợp bạn cần lấy một số bộ nhớ zero sau đó. Linux không có bộ nhớ trước thời hạn và Dragonfly BSD gần đây cũng đã loại bỏ tính năng này khỏi kernel của họ . Tuy nhiên, một số hạt nhân khác không có bộ nhớ trước thời hạn. Các trang zeroing không hoạt động nhàn rỗi không đủ để giải thích sự khác biệt hiệu suất lớn.
Các calloc()
chức năng không sử dụng một số phiên bản bộ nhớ liên kết đặc biệt của memset()
, và đó sẽ không làm cho nó nhanh hơn nhiều anyway. Hầu hết các memset()
triển khai cho các bộ xử lý hiện đại trông giống như thế này:
function memset(dest, c, len)
// one byte at a time, until the dest is aligned...
while (len > 0 && ((unsigned int)dest & 15))
*dest++ = c
len -= 1
// now write big chunks at a time (processor-specific)...
// block size might not be 16, it's just pseudocode
while (len >= 16)
// some optimized vector code goes here
// glibc uses SSE2 when available
dest += 16
len -= 16
// the end is not aligned, so one byte at a time
while (len > 0)
*dest++ = c
len -= 1
Vì vậy, bạn có thể thấy, memset()
rất nhanh và bạn sẽ không thực sự có được bất cứ điều gì tốt hơn cho các khối bộ nhớ lớn.
Thực tế memset()
là bộ nhớ zero đã bị xóa không có nghĩa là bộ nhớ bị xóa 0 lần, nhưng điều đó chỉ giải thích sự khác biệt hiệu năng gấp 2 lần. Sự khác biệt hiệu suất ở đây lớn hơn nhiều (tôi đo được hơn ba bậc độ lớn trên hệ thống của tôi giữa malloc()+memset()
và calloc()
).
Đảng lừa
Thay vì lặp 10 lần, hãy viết chương trình phân bổ bộ nhớ cho đến khi malloc()
hoặc calloc()
trả về NULL.
Điều gì xảy ra nếu bạn thêm memset()
?