ทำไม malloc + memset ช้ากว่า calloc


256

เป็นที่รู้จักกันว่าcallocแตกต่างจากmallocที่เริ่มต้นการจัดสรรหน่วยความจำ ด้วยcallocหน่วยความจำถูกตั้งค่าเป็นศูนย์ ด้วยmallocหน่วยความจำจะไม่ถูกล้างออก

ดังนั้นในการทำงานในชีวิตประจำวันผมถือว่าcallocเป็น+malloc memsetบังเอิญเพื่อความสนุกฉันได้เขียนโค้ดต่อไปนี้เพื่อเป็นเกณฑ์มาตรฐาน

ผลที่ได้คือความสับสน

รหัส 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++;
        }
}

ผลลัพธ์ของรหัส 1:

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

รหัส 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++;
        }
}

ผลลัพธ์ของรหัส 2:

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

การแทนที่memsetด้วยbzero(buf[i],BLOCK_SIZE)ในรหัส 2 จะให้ผลลัพธ์เหมือนกัน

คำถามของฉันคือเหตุใดmalloc+ memsetจึงช้ากว่าcallocมาก จะcallocทำเช่นนั้นได้อย่างไร

คำตอบ:


455

เวอร์ชั่นสั้น: ใช้calloc()แทนmalloc()+memset()ทุกครั้ง ในกรณีส่วนใหญ่พวกเขาจะเหมือนกัน ในบางกรณีcalloc()จะทำงานน้อยลงเพราะสามารถข้ามได้memset()ทั้งหมด ในกรณีอื่น ๆcalloc()สามารถโกงและไม่จัดสรรหน่วยความจำได้! อย่างไรก็ตามmalloc()+memset()จะทำงานเต็มจำนวนเสมอ

การทำความเข้าใจสิ่งนี้ต้องมีการทัวร์ระยะสั้น ๆ ของระบบหน่วยความจำ

ทัวร์หน่วยความจำอย่างรวดเร็ว

มีสี่ส่วนหลักที่นี่: โปรแกรมของคุณไลบรารีมาตรฐานเคอร์เนลและตารางหน้า คุณรู้จักโปรแกรมของคุณอยู่แล้วดังนั้น ...

ตัวจัดสรรหน่วยความจำชอบmalloc()และcalloc()ส่วนใหญ่อยู่ที่นั่นเพื่อทำการจัดสรรขนาดเล็ก (อะไรก็ได้ตั้งแต่ 1 ไบต์ถึง 100s ของ KB) และจัดกลุ่มไว้ในกลุ่มหน่วยความจำขนาดใหญ่ ตัวอย่างเช่นถ้าคุณจัดสรร 16 ไบต์malloc()แรกจะพยายามรับ 16 ไบต์จากหนึ่งในสระแล้วขอหน่วยความจำเพิ่มเติมจากเคอร์เนลเมื่อสระว่ายน้ำแห้ง อย่างไรก็ตามเนื่องจากโปรแกรมที่คุณถามกำลังจัดสรรหน่วยความจำจำนวนมากในคราวเดียวmalloc()และcalloc()จะขอหน่วยความจำนั้นโดยตรงจากเคอร์เนล เกณฑ์สำหรับพฤติกรรมนี้ขึ้นอยู่กับระบบของคุณ แต่ฉันเคยเห็น 1 MiB ใช้เป็นเกณฑ์

เคอร์เนลรับผิดชอบการจัดสรร RAM จริงให้กับแต่ละกระบวนการและทำให้แน่ใจว่ากระบวนการไม่รบกวนหน่วยความจำของกระบวนการอื่น สิ่งนี้เรียกว่าการป้องกันหน่วยความจำมันเป็นสิ่งสกปรกทั่วไปมาตั้งแต่ทศวรรษ 1990 และเป็นสาเหตุที่ทำให้โปรแกรมหนึ่งสามารถทำงานผิดพลาดได้โดยไม่ทำให้ระบบทั้งหมดพัง ดังนั้นเมื่อโปรแกรมที่ต้องการหน่วยความจำมากขึ้นก็ไม่สามารถใช้เวลาเพียงแค่ความทรงจำ แต่จะถามสำหรับหน่วยความจำจากเมล็ดโดยใช้สายระบบเหมือนหรือmmap() sbrk()เคอร์เนลจะให้ RAM กับแต่ละกระบวนการโดยการปรับเปลี่ยนตารางหน้า

ตารางหน้าจะแมปหน่วยความจำกับ RAM จริงจริง ที่อยู่ของกระบวนการของคุณ 0x00000000 ถึง 0xFFFFFFFF ในระบบ 32 บิตไม่ใช่หน่วยความจำจริง แต่เป็นที่อยู่ในหน่วยความจำเสมือน หน่วยประมวลผลแบ่งที่อยู่เหล่านี้ออกเป็น 4 หน้า KiB และแต่ละหน้าสามารถกำหนดให้กับชิ้นส่วนของ RAM ทางกายภาพที่แตกต่างกันโดยการปรับเปลี่ยนตารางหน้า เคอร์เนลเท่านั้นที่ได้รับอนุญาตให้แก้ไขตารางหน้า

มันไม่ทำงาน

นี่คือวิธีการจัดสรร 256 MiB ไม่ทำงาน:

  1. กระบวนการของคุณโทรcalloc()และถาม 256 MiB

  2. ห้องสมุดมาตรฐานโทรmmap()และขอ 256 MiB

  3. เคอร์เนลค้นหา RAM ที่ไม่ได้ใช้ขนาด 256 MiB และมอบให้กับกระบวนการของคุณโดยแก้ไขตารางหน้า

  4. ห้องสมุดมาตรฐาน zeroes แรมด้วยและผลตอบแทนจากmemset()calloc()

  5. ในที่สุดกระบวนการของคุณจะออกและเคอร์เนลจะเรียกคืน RAM เพื่อให้กระบวนการอื่นสามารถใช้งานได้

มันใช้งานได้จริง

กระบวนการข้างต้นจะได้ผล แต่ก็ไม่เกิดขึ้นเช่นนี้ มีความแตกต่างที่สำคัญสามประการ

  • เมื่อกระบวนการของคุณได้รับหน่วยความจำใหม่จากเคอร์เนลอาจมีการใช้หน่วยความจำนั้นโดยกระบวนการอื่นก่อนหน้านี้ นี่คือความเสี่ยงด้านความปลอดภัย ถ้าหน่วยความจำนั้นมีรหัสผ่าน, คีย์เข้ารหัส, หรือสูตรลับซัลซ่า? เพื่อป้องกันไม่ให้ข้อมูลที่มีความสำคัญรั่วไหลเคอร์เนลจะขัดหน่วยความจำเสมอก่อนที่จะส่งไปยังกระบวนการ เราอาจขัดหน่วยความจำโดย zeroing และถ้าหน่วยความจำใหม่เป็นศูนย์เราอาจทำให้การรับประกันดังนั้นmmap()รับประกันว่าหน่วยความจำใหม่ที่ส่งกลับจะเป็นศูนย์เสมอ

  • มีโปรแกรมมากมายที่จัดสรรหน่วยความจำ แต่ไม่ใช้หน่วยความจำทันที บางครั้งมีการจัดสรรหน่วยความจำ แต่ไม่เคยใช้ เคอร์เนลรู้เรื่องนี้และขี้เกียจ เมื่อคุณจัดสรรหน่วยความจำใหม่เคอร์เนลจะไม่แตะที่ตารางหน้าเลยและจะไม่ให้ RAM กับกระบวนการของคุณ แต่จะค้นหาพื้นที่ที่อยู่บางส่วนในกระบวนการของคุณจดบันทึกสิ่งที่ควรจะไปที่นั่นและให้สัญญาว่าจะใส่ RAM ที่นั่นหากโปรแกรมของคุณใช้งานจริง เมื่อโปรแกรมของคุณพยายามอ่านหรือเขียนจากที่อยู่เหล่านั้นตัวประมวลผลจะทริกเกอร์ข้อผิดพลาดของหน้าและขั้นตอนเคอร์เนลในการกำหนด RAM ให้กับที่อยู่เหล่านั้นและดำเนินการโปรแกรมของคุณต่อ หากคุณไม่เคยใช้หน่วยความจำความผิดพลาดของหน้าจะไม่เกิดขึ้นและโปรแกรมของคุณจะไม่ได้รับ RAM จริง

  • บางกระบวนการจัดสรรหน่วยความจำแล้วอ่านจากมันโดยไม่ต้องแก้ไข ซึ่งหมายความว่าหน้าจำนวนมากในหน่วยความจำในกระบวนการที่แตกต่างกันอาจเต็มไปด้วยศูนย์ที่กลับมาจากmmap()เดิม เนื่องจากหน้าเหล่านี้เหมือนกันทั้งหมดเคอร์เนลทำให้ที่อยู่เสมือนเหล่านี้ชี้ไปที่หน้าหน่วยความจำ 4 KiB ที่ใช้ร่วมกันซึ่งเต็มไปด้วยเลขศูนย์ หากคุณพยายามที่จะเขียนไปยังหน่วยความจำตัวประมวลผลทริกเกอร์ข้อผิดพลาดหน้าอื่นและขั้นตอนเคอร์เนลเพื่อให้หน้าใหม่ของศูนย์ที่ไม่ได้ใช้ร่วมกับโปรแกรมอื่น ๆ

กระบวนการสุดท้ายมีลักษณะเช่นนี้มากขึ้น:

  1. กระบวนการของคุณโทรcalloc()และถาม 256 MiB

  2. ห้องสมุดมาตรฐานโทรmmap()และขอ 256 MiB

  3. เคอร์เนลค้นหาพื้นที่ที่อยู่ที่ไม่ได้ใช้ขนาด 256 MiB ทำการบันทึกเกี่ยวกับพื้นที่ที่อยู่นั้นที่ใช้ในปัจจุบันและส่งคืน

  4. ไลบรารีมาตรฐานรู้ว่าผลลัพธ์ของการmmap()เติมด้วยศูนย์เสมอ (หรือจะได้รับเมื่อ RAM จริง) ดังนั้นจึงไม่ได้สัมผัสหน่วยความจำดังนั้นจึงไม่มีข้อบกพร่องของหน้าและ RAM ไม่เคยได้รับในกระบวนการของคุณ .

  5. ในที่สุดกระบวนการของคุณจะออกและเคอร์เนลไม่จำเป็นต้องเรียกคืน RAM เพราะไม่เคยมีการจัดสรรตั้งแต่แรก

หากคุณใช้memset()เพื่อทำให้หน้าเป็นศูนย์memset()จะทริกเกอร์ข้อผิดพลาดของหน้าทำให้ RAM ได้รับการจัดสรรและจากนั้นให้เป็นศูนย์แม้ว่าจะเต็มไปด้วยศูนย์แล้ว นี้เป็นจำนวนเงินมหาศาลในการทำงานพิเศษและอธิบายว่าทำไมcalloc()จะเร็วกว่าและmalloc() memset()หากใช้หน่วยความจำต่อไปcalloc()ก็ยังเร็วกว่าmalloc()และmemset()แตกต่างกัน แต่ก็ไม่ได้ไร้สาระ


มันไม่ได้ผลเสมอไป

ไม่ใช่ทุกระบบที่มีหน่วยความจำเสมือนเพจดังนั้นบางระบบไม่สามารถใช้การปรับให้เหมาะสมเหล่านี้ได้ สิ่งนี้ใช้กับโปรเซสเซอร์เก่ามากเช่น 80286 รวมถึงโปรเซสเซอร์แบบฝังซึ่งเล็กเกินไปสำหรับหน่วยจัดการหน่วยความจำที่ซับซ้อน

สิ่งนี้จะไม่ทำงานกับการจัดสรรที่เล็กกว่าเสมอ ด้วยการจัดสรรที่น้อยลงcalloc()รับหน่วยความจำจากพูลที่แบ่งใช้แทนที่จะไปที่เคอร์เนลโดยตรง โดยทั่วไปพูลที่แบ่งใช้อาจมีข้อมูลขยะที่จัดเก็บไว้ในหน่วยความจำเก่าที่ใช้และปล่อยด้วยfree()ดังนั้นจึงcalloc()สามารถใช้หน่วยความจำนั้นและการโทรmemset()เพื่อล้างข้อมูลออก การใช้งานทั่วไปจะติดตามว่าส่วนใดของพูลที่แบ่งใช้นั้นเก่าและยังเต็มไปด้วยเลขศูนย์ แต่การปรับใช้ไม่ได้ทำสิ่งนี้ทั้งหมด

กำจัดคำตอบที่ผิดบางอย่าง

เคอร์เนลอาจหรือไม่เป็นศูนย์หน่วยความจำในเวลาว่างขึ้นอยู่กับระบบปฏิบัติการในกรณีที่คุณต้องการรับหน่วยความจำที่เป็นศูนย์ในภายหลัง ลินุกซ์ไม่ได้เป็นศูนย์หน่วยความจำไปข้างหน้าของเวลาและแมลงปอ BSD เมื่อเร็ว ๆ นี้ออกยังคุณลักษณะนี้จากเมล็ดของพวกเขา เมล็ดอื่น ๆ บางหน่วยความจำศูนย์ก่อนเวลาอย่างไรก็ตาม การใช้หน้าจอเป็นศูนย์ในการใช้งานที่ไม่ได้ใช้งานนั้นไม่เพียงพอที่จะอธิบายความแตกต่างของประสิทธิภาพการทำงานขนาดใหญ่ได้

calloc()ฟังก์ชั่นไม่ได้ใช้บางรุ่นหน่วยความจำชิดพิเศษmemset()และที่จะไม่ทำให้มันเร็วขึ้นมากเลยล่ะค่ะ memset()การใช้งานส่วนใหญ่สำหรับโปรเซสเซอร์ที่ทันสมัยมีลักษณะเช่นนี้:

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

ดังนั้นคุณจะเห็นว่าmemset()เร็วมากและคุณจะไม่ได้อะไรที่ดีไปกว่านี้สำหรับหน่วยความจำขนาดใหญ่

ความจริงที่ว่าการmemset()zeroing memory ที่มี zeroed อยู่แล้วนั้นหมายความว่า memory ได้รับ zeroed สองครั้ง แต่นั่นจะอธิบายความแตกต่างของประสิทธิภาพที่ 2x เท่านั้น ความแตกต่างด้านประสิทธิภาพที่นี่มีขนาดใหญ่กว่ามาก (ฉันวัดมากกว่าสามคำสั่งขนาดในระบบของฉันระหว่างmalloc()+memset()และcalloc())

เคล็ดลับปาร์ตี้

แทนที่จะวนลูป 10 ครั้งให้เขียนโปรแกรมที่จัดสรรหน่วยความจำจนกระทั่งmalloc()หรือcalloc()คืนค่า NULL

จะเกิดอะไรขึ้นถ้าคุณเพิ่มmemset()?


7
@Dietrich: การอธิบายหน่วยความจำเสมือนของ Dietrich เกี่ยวกับระบบปฏิบัติการที่จัดสรรศูนย์เติมเต็มหน้าเดียวกันหลาย ๆ ครั้งสำหรับ calloc นั้นง่ายต่อการตรวจสอบ เพียงเพิ่มการวนซ้ำที่เขียนข้อมูลขยะในทุก ๆ หน้าหน่วยความจำที่จัดสรร (การเขียนหนึ่งไบต์ทุกๆ 500 ไบต์ควรเพียงพอ) ผลลัพธ์โดยรวมควรใกล้เคียงกันมากขึ้นเนื่องจากระบบจะถูกบังคับให้จัดสรรหน้าแตกต่างในทั้งสองกรณี
kriss

1
@kriss: แน่นอนแม้ว่าหนึ่งไบต์ทุก ๆ 4096 ก็เพียงพอสำหรับระบบส่วนใหญ่
Dietrich Epp

ที่จริงแล้วcalloc()มักจะเป็นส่วนหนึ่งของmallocชุดการดำเนินงานและทำให้เพิ่มประสิทธิภาพในการไม่เรียกเมื่อได้รับจากหน่วยความจำbzero mmap
mirabilos

1
ขอบคุณสำหรับการแก้ไขนั่นคือสิ่งที่ฉันคิดไว้ในใจ ก่อนกำหนดคุณต้องใช้ calloc แทน malloc + memset กรุณาระบุถึง 1. เริ่มต้นเพื่อ malloc 2. หากส่วนเล็ก ๆ ของบัฟเฟอร์จะต้องเป็นศูนย์ memset ส่วนที่ 3 มิฉะนั้นใช้ calloc โดยเฉพาะอย่างยิ่งอย่า malloc + memset ขนาดทั้งหมด (ใช้ calloc สำหรับสิ่งนั้น) และอย่าเริ่มต้นการ callocing ทุกอย่างเนื่องจากมันเป็นตัวขัดขวางสิ่งต่างๆเช่น valgrind และตัววิเคราะห์โค้ดแบบคงที่ นอกจากนั้นฉันคิดว่ามันดี
พนักงานประจำเดือน

5
ในขณะที่ไม่เกี่ยวข้องกับความเร็วcallocก็มีข้อบกพร่องน้อยลง นั่นคือที่large_int * large_intจะส่งผลให้น้ำล้นcalloc(large_int, large_int)ผลตอบแทนNULLแต่malloc(large_int * large_int)เป็นพฤติกรรมที่ไม่ได้กำหนดไว้ในขณะที่คุณไม่ทราบขนาดที่แท้จริงของบล็อกหน่วยความจำถูกส่งกลับ
Dunes

12

เนื่องจากในหลาย ๆ ระบบในเวลาประมวลผลว่าง OS จึงทำการตั้งค่าหน่วยความจำที่ว่างให้เป็นศูนย์ด้วยตัวเองและทำเครื่องหมายว่าปลอดภัยสำหรับcalloc()ดังนั้นเมื่อคุณโทรcalloc()จึงอาจมีหน่วยความจำที่ว่างและไม่มีศูนย์ให้คุณ


2
คุณแน่ใจไหม? ระบบใดทำเช่นนี้? ฉันคิดว่าระบบปฏิบัติการส่วนใหญ่จะปิดตัวประมวลผลเมื่อไม่ได้ใช้งานและมีหน่วยความจำที่เป็นศูนย์ตามความต้องการสำหรับกระบวนการที่จัดสรรทันทีที่เขียนลงหน่วยความจำนั้น (แต่ไม่ใช่เมื่อจัดสรร)
Dietrich Epp

@Dietrich - ไม่แน่ใจ ฉันได้ยินมันครั้งเดียวและดูเหมือนจะเป็นวิธีที่มีเหตุผล (และเรียบง่ายพอสมควร) เพื่อให้calloc()มีประสิทธิภาพมากขึ้น
Chris Lutz

@Pierreten - ฉันไม่สามารถหาข้อมูลที่ดีเกี่ยวกับcalloc()การเพิ่มประสิทธิภาพที่เฉพาะเจาะจงและฉันไม่รู้สึกเหมือนการตีความรหัสที่มา libc สำหรับ OP คุณสามารถค้นหาอะไรก็ได้เพื่อแสดงว่าการเพิ่มประสิทธิภาพนี้ไม่มีอยู่ / ไม่ได้ผล?
Chris Lutz

13
@Dietrich: FreeBSD ควรจะเป็นศูนย์หน้าเติมในเวลาว่าง: ดูการตั้งค่า vm.idlezero_enable
Zan Lynx

1
@DietrichEpp ขออภัยที่ necro แต่สำหรับ Windows ทำเช่นนี้
Andreas Grapentin

1

ในบางแพลตฟอร์มในบางโหมด malloc จะกำหนดค่าเริ่มต้นหน่วยความจำให้เป็นค่าที่ไม่เป็นศูนย์ก่อนที่จะส่งคืนดังนั้นรุ่นที่สองสามารถเริ่มต้นหน่วยความจำได้ดีสองครั้ง

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.