Regex là một trong những công cụ được dùng nhiều nhất mà hiểu ít nhất. Đa số lập trình viên gõ \d+ hay [a-z]* và hy vọng nó "hiểu" ý mình — coi regex như một loại phép thuật so khớp. Nhưng bên trong không có phép thuật nào cả: một engine regex làm đúng hai việc rất cơ học. Thứ nhất, nó biên dịch mẫu của bạn thành một máy trạng thái — một danh sách các trạng thái và luật chuyển. Thứ hai, nó chạy cỗ máy đó trên chuỗi input, ký tự này qua ký tự khác. Hiểu được điều này là nền tảng để hiểu mọi thứ còn lại của cả loạt bài: vì sao regex nhanh hay chậm, vì sao có mẫu làm treo cả server (ReDoS), và vì sao Go/RE2 lại khác Perl/Python. Bài này (phần 1 loạt Regex) mở nắp cỗ máy ra xem tận mắt.

Hai bước: biên dịch rồi chạy

Khi bạn viết re.compile("a(b|c)*d"), engine không lưu lại chuỗi ký tự đó. Nó phân tích cú pháp mẫu và sinh ra một cấu trúc dữ liệu — trong lý thuyết gọi là automaton (máy tự động). Có hai họ:

  • NFA (Nondeterministic Finite Automaton): ở một trạng thái, một ký tự input có thể dẫn tới nhiều trạng thái kế. Các engine kiểu backtracking (Perl, Python re, PCRE, Java) mô phỏng NFA bằng cách thử từng nhánh, quay lui (backtrack) khi hỏng.
  • DFA (Deterministic Finite Automaton): mỗi (trạng thái, ký tự) dẫn tới đúng một trạng thái kế — không cần quay lui. RE2 (Go, và thư viện của Google) dùng cách tiếp cận này (thực chất là NFA mô phỏng kiểu Thompson, hoặc DFA sinh dần), đảm bảo duyệt tuyến tính.

Khác biệt giữa hai họ này chính là chủ đề của phần 6 và 7 (ReDoS và RE2). Nhưng cả hai đều là máy trạng thái — và ta xem được nó bằng công cụ thật.

Ảnh chụp đoạn mã nền tối minh hoạ regex là một máy trạng thái engine khớp chuỗi thế nào NFA DFA python re.DEBUG Go regexp RE2 duyệt input một lần, hiểu lầm regex là phép thuật so khớp thực ra là một cỗ máy engine không đọc hiểu mẫu như con người nó làm hai bước một biên dịch mẫu thành một máy trạng thái danh sách opcode trạng thái hai chạy máy đó trên input ở mỗi ký tự chuyển trạng thái theo bảng hiểu bước hai bằng hiểu vì sao regex nhanh hay chậm khớp hay không, xem máy trạng thái thật bằng python re.DEBUG import re re compile mẫu a bc sao d re.DEBUG in ra AST cộng opcode mà engine biên dịch mẫu thành mẫu này bằng a rồi lặp b hoặc c không hoặc nhiều lần rồi d fullmatch abccbd khớp fullmatch aXd không X không thuộc b c, RE2 Go regexp duyệt input đúng một lần nên tuyến tính re MustCompile mẫu IPv4 thô for range input tăng gấp 5 mỗi bước s Repeat base mul đo thời gian FindAllString quét một lần nếu tăng tuyến tính thì ns mỗi ký tự bằng hằng số

Hình 1: Engine không "đọc hiểu" mẫu mà làm hai bước — biên dịch mẫu thành máy trạng thái (opcode), rồi chạy máy đó trên input; python re.DEBUG cho xem máy trạng thái thật, và Go regexp (RE2) duyệt input một lần nên thời gian tuyến tính.

Đo thật: mở máy trạng thái ra xem

Python có một cờ tuyệt vời để nhìn vào bên trong: re.compile(pattern, re.DEBUG) in ra cả cây cú pháp (AST) lẫn danh sách opcode mà engine sẽ thực thi. Đây không phải hình vẽ minh hoạ — nó là cỗ máy thật mà Python chạy. Mình biên dịch mẫu a(b|c)*d (khớp: một chữ a, rồi lặp b hoặc c không-hoặc-nhiều lần, rồi một chữ d):

Ảnh chụp bảng kết quả chạy thật máy trạng thái regex output thật go-lab python re.DEBUG Go regexp RE2, một opcode thật của mẫu a bc sao d python re.DEBUG 0 INFO 8 0b1 2 MAXREPEAT to 9 prefix 0x61 chữ a biết chắc phải bắt đầu bằng a 9 LITERAL 0x61 chữ a trạng thái khớp a 11 REPEAT 13 0 MAXREPEAT to 25 bắt đầu vòng lặp sao 17 IN 5 to 23 19 RANGE 0x62 0x63 b tới c khớp b hoặc c 25 MAX_UNTIL lặp tối đa rồi đi tiếp 26 LITERAL 0x64 chữ d khớp d 28 SUCCESS đây là máy thật một chuỗi opcode trạng thái engine sẽ chạy fullmatch abccbd khớp fullmatch aXd không, hai RE2 duyệt tuyến tính input gấp 5 thì thời gian cũng gấp 5 mẫu d 1 3 chấm 3 d 1 3 RE2 input ký tự thời gian ns mỗi ký tự 30 nghìn 867 micro giây 28.910 150 nghìn 3.7 mili giây 24.973 750 nghìn 19.7 mili giây 26.279 3.75 triệu 103 mili giây 27.495 18.75 triệu 556 mili giây 29.669 input tăng gấp 5 mỗi bước thời gian cũng tăng khoảng gấp 5 ns mỗi ký tự gần như hằng số 25 tới 30 ns bằng độ phức tạp tuyến tính O n mỗi ký tự xử lý một lần số bước cố định

Hình 2: Chạy thật — re.DEBUG in opcode của a(b|c)*d: LITERAL 'a' → REPEAT/IN RANGE 'b'-'c'/MAX_UNTIL → LITERAL 'd' → SUCCESS; và RE2 trên input từ 30.000 tới 18.750.000 ký tự cho thời gian 867µs → 556ms với ns/ký tự giữ ~25-30 (tuyến tính).

  • Opcode là cỗ máy thật: đọc từ trên xuống, đây chính là chương trình engine chạy. INFO với prefix ['a'] cho biết engine đã tối ưu: nó biết chuỗi khớp bắt buộc bắt đầu bằng a, nên có thể nhảy nhanh tới các vị trí có a. Rồi LITERAL 0x61 (khớp a), REPEAT ... IN RANGE 'b'-'c' ... MAX_UNTIL (vòng lặp khớp b hoặc c), LITERAL 0x64 (khớp d), SUCCESS. Mỗi opcode là một trạng thái; chạy chúng tuần tự trên input chính là "khớp regex".
  • Khớp là chạy máy: fullmatch('abccbd') thành công vì chuỗi đi trọn từ a qua vòng b/c tới d rồi SUCCESS. fullmatch('aXd') thất bại vì tới X, opcode IN RANGE 'b'-'c' không chấp nhận X, máy dừng.
  • RE2 duyệt tuyến tính — đo thật: mình cho RE2 (Go regexp) khớp mẫu IPv4 trên input tăng gấp 5 mỗi bước (30.000 → 150.000 → ... → 18.750.000 ký tự). Thời gian cũng tăng gấp ~5 mỗi bước (867µs → 3.7ms → 19.7ms → 103ms → 556ms), và ns/ký tự giữ gần như hằng số (~25-30ns). Đây là chữ ký của độ phức tạp tuyến tính O(n): cỗ máy trạng thái xử lý mỗi ký tự đúng một lần với số bước cố định. Chính tính chất này khiến RE2 an toàn trước ReDoS (phần 7) — điều mà engine backtracking không đảm bảo.

Đánh đổi cần cân nhắc

re.DEBUG của Python cho thấy máy backtracking, không phải DFA của RE2. Opcode ta xem ở trên là của engine re Python (kiểu NFA-backtracking). RE2 của Go biên dịch sang một dạng khác (chương trình NFA chạy song song các trạng thái, hoặc DFA sinh dần), không xem được bằng re.DEBUG. Hai engine cùng là "máy trạng thái" nhưng cách chạy máy khác nhau — và đó là gốc rễ của khác biệt hiệu năng ở các phần sau. Đừng nghĩ mọi engine regex đều làm giống nhau.

Biên dịch mẫu tốn công — nên làm một lần. Bước biên dịch mẫu thành máy trạng thái không miễn phí. Nếu bạn gọi regexp.MustCompile/re.compile bên trong vòng lặp, bạn trả chi phí biên dịch lại mỗi lần. Luôn biên dịch một lần và tái dùng đối tượng regex đã biên dịch (chi tiết ở phần 11 về tối ưu). Đây là lỗi hiệu năng regex phổ biến nhất trong thực tế.

Tuyến tính theo độ dài input, nhưng hằng số phụ thuộc mẫu. RE2 đảm bảo O(n) theo độ dài input, nhưng con số ns/ký tự (hằng số nhân) phụ thuộc độ phức tạp của mẫu: một mẫu nhiều nhánh, nhiều lớp ký tự lớn sẽ có hằng số cao hơn. "Tuyến tính" không có nghĩa "luôn nhanh" — nó có nghĩa "không bùng nổ theo input". Với engine backtracking thì ngay cả điều đó cũng không được đảm bảo.

Ba ý mang về

  1. Regex là máy trạng thái, không phải phép thuật: engine biên dịch mẫu thành opcode/trạng thái (xem thật bằng python re.DEBUG: LITERAL, REPEAT, IN RANGE, MAX_UNTIL, SUCCESS) rồi chạy máy đó trên input — khớp chính là chạy cỗ máy qua từng ký tự.
  2. RE2 duyệt input tuyến tính: đo thật input tăng gấp 5 (30.000 → 18.750.000 ký tự) thì thời gian cũng tăng ~gấp 5 (867µs → 556ms), ns/ký tự hằng số ~25-30 — độ phức tạp O(n), mỗi ký tự xử lý một lần.
  3. Không phải engine nào cũng giống nhau: NFA-backtracking (Python/Perl/PCRE) và DFA/RE2 (Go) cùng là máy trạng thái nhưng chạy khác nhau — đây là gốc của mọi khác biệt hiệu năng ở các phần sau; và luôn nhớ biên dịch mẫu một lần rồi tái dùng.

Nguồn

Phần sau ta mổ xẻ một cặp gây bối rối nhất: tham lam (.*) và lười (.*?) — chúng khớp khác nhau ra sao, và vì sao chọn sai làm engine phải quay lui nhiều hơn hẳn.