Tại sao malloc + memset chậm hơn calloc?


256

Nó được biết calloclà khác với mallocở chỗ nó khởi tạo bộ nhớ được phân bổ. Với calloc, bộ nhớ được đặt thành không. Với malloc, bộ nhớ không bị xóa.

Vì vậy, trong công việc hàng ngày, tôi coi callocmalloc+ memset. Ngẫu nhiên, để giải trí, tôi đã viết đoạn mã sau cho điểm chuẩn.

Kết quả là khó hiểu.

Mã 1:

#include<stdio.h>
#include<stdlib.h>
#define BLOCK_SIZE 1024*1024*256
int main()
{
        int i=0;
        char *buf[10];
        while(i<10)
        {
                buf[i] = (char*)calloc(1,BLOCK_SIZE);
                i++;
        }
}

Đầu ra của Mã 1:

time ./a.out  
**real 0m0.287s**  
user 0m0.095s  
sys 0m0.192s  

Mã 2:

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#define BLOCK_SIZE 1024*1024*256
int main()
{
        int i=0;
        char *buf[10];
        while(i<10)
        {
                buf[i] = (char*)malloc(BLOCK_SIZE);
                memset(buf[i],'\0',BLOCK_SIZE);
                i++;
        }
}

Đầu ra của Mã 2:

time ./a.out   
**real 0m2.693s**  
user 0m0.973s  
sys 0m1.721s  

Thay thế memsetbằng bzero(buf[i],BLOCK_SIZE)trong Mã 2 tạo ra kết quả tương tự.

Câu hỏi của tôi là: Tại sao malloc+ memsetchậm hơn nhiều so với calloc? Làm thế nào có thể calloclàm điều đó?

Câu trả lời:


455

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()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()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:

  1. Quá trình của bạn gọi calloc()và yêu cầu 256 MiB.

  2. Thư viện tiêu chuẩn gọi mmap()và yêu cầu 256 MiB.

  3. 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.

  4. Thư viện tiêu chuẩn thay đổi RAM với memset()và trả về calloc().

  5. 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:

  1. Quá trình của bạn gọi calloc()và yêu cầu 256 MiB.

  2. Thư viện tiêu chuẩn gọi mmap()và yêu cầu 256 MiB.

  3. 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ề.

  4. 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 .

  5. 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()memset(). Nếu cuối cùng vẫn sử dụng bộ nhớ, calloc()vẫn nhanh hơn malloc()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()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()?


7
@Dietrich: giải thích bộ nhớ ảo của Dietrich về hệ điều hành phân bổ cùng một trang không có nhiều lần cho calloc rất dễ kiểm tra. Chỉ cần thêm một số vòng lặp ghi dữ liệu rác trong mỗi trang bộ nhớ được phân bổ (viết một byte cho mỗi 500 byte là đủ). Kết quả tổng thể sau đó sẽ trở nên gần gũi hơn nhiều vì hệ thống sẽ buộc phải thực sự phân bổ các trang khác nhau trong cả hai trường hợp.
kriss

1
@kriss: thực sự, mặc dù một byte mỗi 4096 là đủ trên phần lớn các hệ thống
Dietrich Epp

Trên thực tế, calloc()thường là một phần của bộ malloctriển khai và do đó được tối ưu hóa để không gọi bzerokhi nhận bộ nhớ mmap.
mirabilos

1
Cảm ơn bạn đã chỉnh sửa, đó gần như là những gì tôi đã nghĩ. Ban đầu, bạn luôn luôn sử dụng calloc thay vì malloc + memset. Vui lòng nêu lên 1. mặc định cho malloc 2. nếu một phần nhỏ của bộ đệm cần bằng 0, hãy nhớ phần đó 3. nếu không thì sử dụng calloc. Cụ thể là KHÔNG malloc + ghi nhớ toàn bộ kích thước (sử dụng calloc cho điều đó) và KHÔNG mặc định gọi lại mọi thứ vì nó cản trở những thứ như valgrind và máy phân tích mã tĩnh (tất cả bộ nhớ được khởi tạo đột ngột). Khác hơn là tôi nghĩ rằng điều này là tốt.
nhân viên của tháng

5
Trong khi không liên quan đến tốc độ, calloccũng ít bị lỗi hơn. Đó là, nơi large_int * large_intsẽ dẫn đến tràn, calloc(large_int, large_int)trả về NULL, nhưng malloc(large_int * large_int)là hành vi không xác định, vì bạn không biết kích thước thực tế của khối bộ nhớ được trả về.
Cồn cát

12

Bởi vì trên nhiều hệ thống, trong thời gian xử lý dự phòng, HĐH sẽ tự đặt bộ nhớ trống về 0 và đánh dấu nó an toàn calloc(), vì vậy khi bạn gọi calloc(), nó có thể có bộ nhớ trống, không có bộ nhớ để cung cấp cho bạn.


2
Bạn có chắc không? Những hệ thống nào làm điều này? Tôi nghĩ rằng hầu hết các hệ điều hành chỉ tắt bộ xử lý khi chúng không hoạt động và không có bộ nhớ theo yêu cầu cho các quy trình được phân bổ ngay khi chúng ghi vào bộ nhớ đó (nhưng không phải khi chúng phân bổ nó).
Dietrich Epp

@Dietrich - Không chắc chắn. Tôi đã nghe nó một lần và nó có vẻ như là một cách hợp lý (và hợp lý đơn giản) để làm cho calloc()hiệu quả hơn.
Chris Lutz

@Pierreten - Tôi không thể tìm thấy bất kỳ thông tin tốt nào về calloc()tối ưu hóa cụ thể và tôi không cảm thấy muốn diễn giải mã nguồn libc cho OP. Bạn có thể tra cứu bất cứ điều gì để cho thấy rằng tối ưu hóa này không tồn tại / không hoạt động?
Chris Lutz

13
@Dietrich: FreeBSD được cho là không có trang nào trong thời gian rảnh: Xem cài đặt vm.idlezero_enable của nó.
Zan Lynx

1
@DietrichEpp xin lỗi necro, nhưng ví dụ Windows làm điều này.
Andreas Grapentin

1

Trên một số nền tảng trong một số chế độ, malloc khởi chạy bộ nhớ thành một số giá trị khác không trước khi trả lại, vì vậy phiên bản thứ hai cũng có thể khởi tạo bộ nhớ hai lần

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.