+1

Back to basic

Mở đầu

Bài viết này được mình dịch ra từ một một Blog mà mình rất tâm đắc của Joel Spolsky, bài viết này cực kỳ có ích cho cả những người mới lập trình cũng như những người đã có nhiều năm kinh nghiệm. Bản thân mình, là một người đã được học chương trình đại học 4 năm, trong quá trình đó mình đã được dạy rất nhiều về những vấn đề sâu nhất của lập trình. Tuy nhiên trong quá trình học mình luôn đặt ra câu hỏi là những thứ đó để làm gì, đôi khi có những thứ quan trọng và rất cần cho thời điểm hiện tại thì mình đã bỏ qua. Trong quá trình đi làm, mình đã có thời gian dài sử dụng một số ngôn ngữ hỗ trợ hết những phần sâu bên dưới và chúng tự giải quyết vấn đề theo cách chúng muốn, đôi khi làm nhoà đi khoảng cách giữa ngôn ngữ lập trình bậc cao và kiến trúc máy tính. Điều đó tốt cho một vài trường hợp nhưng không phải tất cả, nó giúp bạn code nhanh hơn nhưng không giúp bạn có những giải pháp để tối ưu dự án của mình.

Nội dung

Hãy tưởng tượng những dự án của bạn là một chiếc bánh có nhiều lớp. Vậy thì chiếc bành này có những tầng nào.

  1. Tầng cao nhất là software strategy, ở đây chúng ta cố gắng nghĩ ra những tính năng mới cho dự án của mình, cải thiện những gì user cần để giúp dự án của mình tốt hơn về mặt business.
  2. Tầng tiếp theo là những thứ liên quan đến architectures như .NET
  3. Tầng tiếp theo là những product riêng lẻ: Sản phẩm phát triển phần mềm như Java hoặc nền tảng như Windows
  4. ... Bạn nghĩ còn bao nhiêu tầng nữa? Tầng DLLs (thư viện)? Object? Function? Không, sâu hơn nữa. Đến khi nào bạn nghĩ về những dòng code viết bằng ngồng ngữ lập trình. Đến đó vẫn chưa đủ. Bài viết này muốn bạn suy nghĩ đến những thứ sâu hơn như CPUs. Không biết ở đây ngày xưa có hay xem mấy phim cổ trang kiếm hiệp của mấy khứa Tàu không. Trong phim hay có các bí kíp để trở thành một vô địch thiên hạ, trong bí kíp đó cần bạn phải phế hết võ công mà mình đã học trước đó và bắt đầu lại từ đầu. Đúng vậy, nếu bạn là người mới vào nghề. Hãy xé bỏ hết những kinh nghiệm tích luỹ được về lập trình, phần mềm, quản lý và quay lại với những thứ cơ bản nhất. Đó là byte.

Tại sao chúng ta phải làm như vậy? Tôi nghĩ rằng những sai lầm lớn nhất của các lập trình viên thường mắc phải đến từ việc dev có một sự hiểu biết yêu kém hoặc sai trái về những tầng thấp nhất của việc lập trình. Giống như xây một lâu đài trên cát vậy, chúng ta cứ đi nghĩ làm sao để xây một lâu đài nguy nga tráng lệ nhưng quên mất rằng để nó tồn tại và vững chắc thì cần phải xây dựng một nền móng thật kiên cố. Lâu đài càng cao, thì khi phát hiện ra vấn đề ở phần nền móng càng khó xử lý.

Hãy cùng tôi làm một bài tập nhỏ về ngôn ngữ lập trình C. Đối với string trong C: Chúng bao gồm một loạt byte theo sau là ký tự null, có giá trị 0. Điều này có hai hàm ý

  1. Một chuỗi có độ dài không xác định, vậy làm sao để biết được độ dài của nó. Chỉ có một cách là duyệt qua hết độ dài chuỗi đó đến khi nào gặp ký tự null thì dừng lại.
  2. "Binary blob" thường được sử ụng để chỉ một dữ liệu nhị phân không cấu trúc hoặc không được hiểu rõ ngữ nghĩa ngay từ đầu. Trong kỹ thuật máy tính, "binary blob thường ám chỉ một tập dữ liệu nhị phân chẳng hạn như một tập tin không cấu trúc, một luồng dữ liệu từ thiết bị. Một JPEG là một dạng "binary blob" vì nó bao gồm một chuỗi các byte chứa dữ liệu nhị phân mà không có cấu trúc rõ ràng khi nhìn vào nó. Một binary blob là một JPEG thường chứa nhiều byte với giá trị từ 0->255 bao gồm cả null. Do đó không thể lưu trữ nó như một string trong C. Vậy câu hỏi đặt ra là vì sao C lại làm việc như vậy? Đó là bởi vì PDP-7 microprocessor, nơi mà UNIX và ngôn ngữ lập trình C được phát minh ra có một ASCIZ string type. ASCIZ có nghĩa là "ASCIZ string a Z (zero) at the end".

Đây có phải chỉ là cách duy nhất để lưu một string? Không, trên thực tế đó là một trong những cách tồi tệ nhất để lưu trữ string. Đối với non-trivial programs, APIs, operating systems, class libraries, bạn nên tránh ASCIZ string. Tại sao vậy? Hãy bắt đầu bằng cách viết một phiên bản code cho strcat, hàm nối string này với string khác.

void strcat( char* dest, char* src )
{
    while (*dest) dest++;
    while (*dest++ = *src++);
}

Đoạn code trên đang làm gì? dòng lệnh while (*dest) dest++; di chuyển con trỏ dest đến vị trí cuối cùng của string dest, nới mà ký tự kết thúc string null được đặt. Dòng tiếp theo có nhiệm vụ nối string (src) vào cuối string đích (dest). Việc thực hiện gán dest bằng src và tăng con trỏ cho đến khi gặp ký tự kết thúc string. Kiểu concatenation này trông có vẻ ổn, nhưng nó có vấn đề riêng. Giả sử bạn có một loạt tên mà muốn nối lại với nhau thành một string lớn:

    char bigString[1000];
    bigString[0] = '\0';
    strcat(bigString, "John, ");
    strcat(bigString, "Paul, ";
    strcat(bigString, "George, ");
    strcat(bigString, "Joel ");

Đoạn code trên có work không? Có, trông nó có vẻ tuyệt và clean. Tuy nhiên câu hỏi đặt ra là performance của nó có tốt không? Nó có nhanh không? Nó có khả năng scale tốt không? Nếu chúng ta có hàng triệu string cần thêm vào, nó có còn là một cách tốt để làm không? Câu trả lời là không. Đoạn code này sử dụng Shlemiel the painter’s algorithm. Shlemiel là một hoạ sĩ đường phố được thuê để vẽ một đoạn đường rất dài. Ngày đầu tiên anh ấy vẽ được 300 thước đường, ông chủ thấy vậy và khen thưởng cho Shlemiel vì sẽ rất nhanh. Ngày tiếp theo anh ta chỉ vẽ được 150 thước. Tuy nhiên ông chủ vẫn khen anh ta vẽ nhanh và khen thưởng. Đến ngày thứ 3 anh ta chỉ vẽ được 30 thước. Ông sếp không thể chấp nhận được chuyện này và hỏi anh ta vì sao. Anh ta đáp lại rằng anh ta không thể thay đổi được gì, vì cứ mỗi ngày anh ta lại đi xa nơi lấy màu vẽ hơn. "Every day I get father and father away from the pait can!" Phần đầu tiên của hàm strcat phải quét qua chuỗi địch mỗi lần, tìm lại ví trí null trong string để thêm vào từ điểm đó, nên hàm này chậm hơn nhiều so với mức caafn thiết và đang khó scale rất nhiều. Rất nhiều đoạn code đang sử dụng hằng ngày đều gặp phải vấn đề này. Nhiều hệ thống tệp được triển khai theo cách mà việc đặt quá nhiều tệp vào một thư mục là một ý tưởng tồi vì hiệu suất bắt đầu giảm đáng kể khi bạn nhận được hàng nghìn mục trong thư mục. Hãy thử mở thùng rác trong window đã quá tải để xem tính năng này hoạt động. Phải mất hàng giờ để hiển thị, con số này rõ ràng không tuyến tính về số lượng tệp chứa trong đó. Làm cách nào để cải thiện hàm strcat trở nên tốt hơn, hay ít nhất là không gặp phải vấn đề như vừa đề cập.

char* mystrcat( char* dest, char* src )
{
    while (*dest) dest++;
    while (*dest++ = *src++);
    return --dest;
}

Bằng việc tăng lên một chút chi phí từ việc return ra con trỏ đến vị trí cuối của string. Bằng cách đó code gọi hàm này có thể quyết định nối thêm mà không cần quét lại toàn bộ string.

char bigString[1000]
char *p = bigString;
bigString[0] = '\0';
p = mystrcat(p,"John, ");
p = mystrcat(p,"Paul, ");
p = mystrcat(p,"George, ");
p = mystrcat(p,"Joel ");

Bằng cách này, performance sẽ là linear chứ không phải cấp số mũ như ban nãy. Dev sử dụng Pascal không cần quan tâm đến vấn đề này bởi vì nó đã được fix bằng cách lưu một byte count tại byte đầu tiền của string. Đây được gọi là Pascal string. Nó có thể chứa zeros và not null terminated. Bởi vì một byte chỉ có thể chứa một số trong khoảng 0 đến 255, Pascal string bị giới hạn độ dài 255 bytes, đó cùng là lý do vì sao Excel hoạt động rất nhanh. Trước kia, nếu bạn muốn put một Pascal string vào code C, bạn cần phải viết:

char* str = "\006Hello!";

Đúng vậy, bạn cần phải đếm bằng tay độ dài byte, và sau đó hardcode nó vào byte đầu tiên của string. Lazy programmers sẽ làm như thế này, và làm chương trình trở nên chậm hơn:

char* str = "*Hello!";
str[0] = strlen(str) - 1;

Lưu ý rằng trong trường hợp này bạn có một chuỗi bị kết thúc bằng null (trình biên dịch đã làm điều đó) cũng như một chuỗi Pascal. Tôi thường gọi string này là Fuced strings bởi vì nó dễ hơn gọi là Null terminated pascal strings Nhưng trong blog này có văn hoá nên sẽ phải gọi chúng bằng tên dài hơn. Tác giả nói vậy chứ không phải mình 🖖 Tôi đã bỏ qua một vấn đề quan trong trước đó. Bạn có nhớ dòng code này không?

char bigString[1000];  /* I never know how much to allocate */

Tôi cần tìm ra số byte cần và phân bổ lượng bộ nhớ phù hợp. Vậy có nên làm như thế không? Một hacker sẽ đọc được đoạn code này và biết rằng tôi đang cố gắng hy vọng vùng nhớ này là vừa đủ, và sau đó họ sẽ tìm một vài cách thông minh để khiến chương trình của tôi phải nối một string 1100 bytes, dó đó overwrite stack frame và thay đổi địa chỉ trả về, vì vậy khi hàm trên được chạy, nó thực thi code mà hacker đã viết. Đó là nguyên nhân số một gây ra các vụ hack và worms ngày xưa trước khi mà Microsoft Outlook khiến việc hack trở nên dễ dàng. Câu này mình mình cũng không hiểu lắm, đại khái là tác giả muốn nói rằng những nguyên nhân chuyên sâu bến dưới có nguy cơ tiềm ẩn bị hack rất lớn. Nhưng C không làm điều này dễ dàng với bạn, hay quay lại ví dụ:

char bigString[1000];  /* I never know how much to allocate */
char *p = bigString;
bigString[0] = '\0';
p = mystrcat(p,"John, ");
p = mystrcat(p,"Paul, ");
p = mystrcat(p,"George, ");
p = mystrcat(p,"Joel ");

Bảo nhiêu bytes là đủ? hãy thử một cách đúng đắn hơn:

char* bigString;
int i = 0;
i = strlen("John, ")
+ strlen("Paul, ")
+ strlen("George, ")
+ strlen("Joel ");
bigString = (char*) malloc (i + 1);
/* remember space for null terminator! */
...

Memory allocators. Bạn có biết malloc hoạt động như thế nào không? Bản chất malloc là nó có một danh sách dài các khối bộ nhớ có sẵn được liên kết dài được gọi là free chain. Khi bạn yêu cầu một vùng nhớ bằng cách call qua malloc, nó sẽ tìm trong danh sách các block trống đủ so với phần bạn yêu cầu. Sau đó nó sẽ cắt block lớn vào 2 block, một phần đủ cho phần bạn yêu cầu, và phần dư ra được giữ lại. Sau đó sẽ trả cho bạn block memory mà bạn cần. Khi bạn call free, nó sẽ add block bạn trả lại vào free chain. Nếu free chain chỉ toàn là những mảnh nhỏ và không có mảnh nào phù hợp với phần bạn yêu cầu. Malloc sẽ tìm hết các mảnh và hợp nhất các block liền kề thành các block lớn. Việc này mất khoảng 3 ngày rưỡi. Kết quả cuối cùng của tất cả sự lộn xộn này là performance của malloc không nhanh và đôi khi không thể đoán trước được, nó chậm một cách đáng kinh ngạc trong khi dọn dẹp. (Tiện đây thì đó là performance tương tự của hệ thống thu gom rác, thật bất ngờ, vì vậy tất cả những tuyên bố mà mọi người đưa ra về cách thu gom rác áp đặt performance penalty là không hoàn toàn đúng, việc triển khai malloc điển hình có cùng loại performance penalty, mặc dù nhẹ hơn.) Các lập trình viên thông minh giảm thiểu sự gián đoạn tiềm tàng của maclloc bằng cách luôn phân bổ các block memory có kích thước bằng các luỹ thừa của 2 (4 bytes, 8bytes, 16 bytes, 32 bytes,...). Vì những lý do trực quan đối với bất kỳ ai chơi Lego, điều này giúp giảm thiểu những phân mảnh. Cũng có thể dễ dàng nhận thấy nó không bao giờ lãng phí quá 50% dung lượng. Dù sao đi nữa, cuộc sống ngày càng trở nên lộn xộn hơn ở phần byte-land. Bạn không vui vì không phải viết bằng C nữa sao? Những ngôn ngữ tối ưu hơn được ra đời, khiến bạn không cần nghĩ về những vấn đề tương tự, họ chỉ giải quyết chúng bằng một cách nào đó. Nhưng thỉnh thoảng nó vẫn xảy ra, và chúng ta cần suy nghĩ liệu sử dụng String hay String Builder class hoặc một thứ gì đó tương tự, bởi trình biên dịch chưa đủ thông minh để hiểu mọi thứ về những gì chúng ta đang cố gắng hoàn thành và đang cố gắng giúp chung ta không vô tình viết các thuật toán Shlemiel the Painter. Tuần trước tôi đã viết rằng bạn không thể triển khai nhanh câu lệnh SQL SELECT author From books nhanh khi dữ liệu của bạn được lưu trữ trong XML. Đề phòng trường hợp mọi người không hiểu tôi đang nói về điều gì và bây giờ chúng ta đang loay hoay vơi CPU cà ngày nên khẳng định này có thể hợp lý hơn. (Ông tác giả đang nói gì đây không biết) Cơ sở dữ liệu quan hệ implement SELECT author FROM books như thế nào. Trong cơ sở dữ liệu quan hệ, mọi row trong một table có độ dài giống nhau, và mọi trường luôn được fixed offset 23, do đó có những authors được lưu tại byte 23, 123, 223, 323, ... COde move đến record tiếp theo trong kết quả của truy vấn này là gì? Về cơ bản nó là như thế này: Pointer += 100; Một lệnh CPU. Thật tuyệt với Bây giờ hãy nhìn vào bảng books trong XML

<?xml blah blah>
<books>
<book>
<title>UI Design for Programmers</title>
<author>Joel Spolsky</author>
</book>
<book>
<title>The Chop Suey Club</title>
<author>Bruce Weber</author>
</book>
</books>

Một câu hỏi nhanh. What is the code to move to the next record? Một lập trình viên giỏi sẽ trả lời rằng hãy phân tích cũ pháp XML thành một cây trong bộ nhớ để chúng ta có thể thao tác trên nó một cách hợp lý và nhanh chóng. Khối lượng công việc mà CPU phải thực hiện ở đaay để SELECT author FROM books sẽ khiến bạn chán đến phát khóc. Như mọi người viết compiler đều biết, viết từ vựng và phân tích cú pháp là phần chậm nhất trong quá trình biên dịch. Chỉ cần nói rằng nó liên quan đến rất nhiều nội dung string mà chúng tôi phát hiện ra là chậm và rất nhiều nội dung phân bổ bộ nhớ mà chúng tôi phát hiện ra là chậm, khi chúng tôi lex, parse, và build một abstract syntax tree trong bộ nhớ. Điều đó giả định rằng bạn có đủ bộ nhớ để tải toàn bộ nội dung cùng một lúc. Với DB quan hệ, hiệu suất di chuyển từ record này sang record khác là cố định và trên thực tế là một lệnh CPU. Và nhớ các tệp được ánh xạ bộ nhớ, bạn chỉ phải tải các trang của disk mà bạn thực sử sẽ sử dụng. Với XML, nếu bạn chuẩn bị, hiểu năng để move từ một record sang record khác là cố định nhưng có thời gian khởi động rất lớn, và nếu bạn không prepare, performance của hành động này sẽ phụ thuộc vào độ lớn của record trước đó và vẫn tốn hằng trăm lệnh CPU.

Điều này có ý nghĩa với tôi là bạn không thể sử dụng XML nếu bạn cần hiệu suất và có nhiều dữ liệu. Nếu bạn chỉ có ít dữ liệu, hoặc nhiệm vụ bạn đang thực hiện không cần đến tốc độ xử lý nhanh, XML vẫn có thể đáp ứng được. Ngước lại, nếu bạn thực sự muốn cả hai điều trên thì bạn cần nghĩ ra một cách lưu trữ metadata bên cacnhj XML, có thể là một thứ gì đó như Pascal strings' byte count. Nhưng tất nhiên, bạn không thể sử dụng trình soạn thảo văn bản để chỉnh sửa tệp vì điều đó làm rối metadata, vì nó không thực sự là XML nữa. Khi đọc đến đây, tôi hy vọng rằng việc nghĩ về những thứ nhàm chán trong khoa học máy tính của năm nhất như cách strcatmalloc thực sự hoạt động có thể cung cấp cho bạn các công cụ mới để suy nghĩ về các quyết định mang tính kiến trúc, chiến lược và cấp cao nhất mà bạn đưa ra khi xử lý các công nghệ như XML. Đối với bài tập về nhà, hãy nghĩ xem tại sao chip Transmeta luôn có cảm giác chậm chạp. Hoặc tại sao thông số HTML ban đầu cho. TABLES được thiết kế tệ đến mức những bảng lớn trên trong web không thể hiển thị nhanh chóng cho những người có modems. Hoặc về lý do tại sao COM lại nhanh như vậy nhưng không phải khi bạn vượt qua ranh giới quy trình. Hoặc vì sao bọn NT lại đặt driver hiển thị vào kernalspace thy vì userspace.

Đây là tất cả những điều yêu cầu bạn phải suy nghĩ về byte và chúng ảnh hưởng đến các quyết định cấp cao nhất mà dự án của bạn đang hoặc sẽ triển khai. Đây là lý do tại sao quan điểm giảng dạy của tôi cho sinh viên năm nhất cần bắt đầu từ những điều cơ bản, sử dụng C và phát triển dần từ CPU. Tôi thật sự thấy khó chịu khi có quá nhiều chương trình khoa học máy tính cho rằng Java là ngôn ngữ lập trình nhập môn tốt. Bởi vì nó "dễ dàng" và bạn không nhầm lẫn với tất cả những thứ nhàm chán về string/malloc nhưng bạn có thể học những nội dung OOP thú vị sẽ khiến các chương trình lớn của bạn trở nên modular hoá.

Tổng kết

Trong nội dung trình bày ở trên, có nhiều chỗ mình trình bày theo các nói của tác giả luôn và cũng không hiểu ổng đang nói gì, nhưng về tổng quan thì mình vẫn hiểu. Có thể thấy những vấn để ở tầng thấp nhất ảnh hưởng lớn tới sản phẩm của chúng ta. Theo cá nhân của mình thì bài viết này khá hay, xây dựng cho mình một mindset rằng nếu muốn thực sự kiểm soát và trở thành một lập trình viên giỏi thì cần nắm vững được những gì mình đang sử dụng, từ việc khai báo một biến đến những thứ to tát hơn. Mình đã gặp qua nhiều người đồng nghiệp giỏi, không một ai trong số họ không nắm vững những đặc điểm sâu bên dưới của một ngôn ngữ lập trình. Do đó, thông qua bài này mình muốn xây dựng một tâm thế trước khi bắt đầu học sâu về một ngôn ngữ nào đó, thì cần chú trọng cách chúng hoạt động hơn là làm sao biết được nhiều hơn những thứ mà ngôn ngữ đó có, chẳng hạn như đọc về những hàm ngắn gọn súc tích để viết code nguy hiểm hơn chẳng hạn, hay biết được thật nhiều thư viện bla bla.


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í